summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-11-17 11:33:21 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-11-17 11:33:21 +0000
commit7021455bd1ed7b125c55eb1b33c5a01f2bc55ee0 (patch)
tree5bdc2229f5198d516781f8d24eace62fc7e589e9 /app
parent185b095e93520f96e9cfc31d9c3e69b498cdab7c (diff)
downloadgitlab-ce-7021455bd1ed7b125c55eb1b33c5a01f2bc55ee0.tar.gz
Add latest changes from gitlab-org/gitlab@15-6-stable-eev15.6.0-rc42
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/access_tokens/components/new_access_token_app.vue7
-rw-r--r--app/assets/javascripts/admin/application_settings/runner_token_expiration/index.js2
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete.vue4
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue48
-rw-r--r--app/assets/javascripts/admin/users/components/associations/associations_list.vue65
-rw-r--r--app/assets/javascripts/admin/users/components/associations/associations_list_item.vue27
-rw-r--r--app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue17
-rw-r--r--app/assets/javascripts/admin/users/components/user_actions.vue1
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue2
-rw-r--r--app/assets/javascripts/analytics/shared/components/daterange.vue4
-rw-r--r--app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue2
-rw-r--r--app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue36
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js28
-rw-r--r--app/assets/javascripts/api/groups_api.js8
-rw-r--r--app/assets/javascripts/api/user_api.js6
-rw-r--r--app/assets/javascripts/artifacts/components/artifact_delete_modal.vue54
-rw-r--r--app/assets/javascripts/artifacts/components/artifact_row.vue87
-rw-r--r--app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue118
-rw-r--r--app/assets/javascripts/artifacts/components/job_artifacts_table.vue337
-rw-r--r--app/assets/javascripts/artifacts/constants.js55
-rw-r--r--app/assets/javascripts/artifacts/graphql/cache_update.js30
-rw-r--r--app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql7
-rw-r--r--app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql57
-rw-r--r--app/assets/javascripts/artifacts/index.js29
-rw-r--r--app/assets/javascripts/artifacts/utils.js26
-rw-r--r--app/assets/javascripts/behaviors/copy_code.js3
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js129
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js7
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js4
-rw-r--r--app/assets/javascripts/blob/blob_blame_link.js14
-rw-r--r--app/assets/javascripts/blob/blob_file_dropzone.js96
-rw-r--r--app/assets/javascripts/blob/blob_line_permalink_updater.js6
-rw-r--r--app/assets/javascripts/blob/components/blob_edit_content.vue66
-rw-r--r--app/assets/javascripts/blob/utils.js26
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js22
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js53
-rw-r--r--app/assets/javascripts/boards/components/board_app.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue9
-rw-r--r--app/assets/javascripts/boards/components/board_card_move_to_position.vue15
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue99
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue85
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue20
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue8
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue6
-rw-r--r--app/assets/javascripts/boards/components/board_top_bar.vue10
-rw-r--r--app/assets/javascripts/boards/components/issue_board_filtered_search.vue68
-rw-r--r--app/assets/javascripts/boards/graphql.js15
-rw-r--r--app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql2
-rw-r--r--app/assets/javascripts/boards/index.js8
-rw-r--r--app/assets/javascripts/boards/stores/actions.js5
-rw-r--r--app/assets/javascripts/branches/components/delete_merged_branches.vue171
-rw-r--r--app/assets/javascripts/branches/init_delete_merged_branches.js23
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/delete_pipeline_schedule_modal.vue45
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue256
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue (renamed from app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_form.vue)0
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue (renamed from app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue)2
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue (renamed from app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue)0
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue (renamed from app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue)0
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner.vue (renamed from app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_owner.vue)0
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue (renamed from app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue)0
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue (renamed from app/assets/javascripts/pipeline_schedules/components/table/pipeline_schedules_table.vue)7
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal.vue54
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue (renamed from app/assets/javascripts/pipeline_schedules/components/take_ownership_modal.vue)0
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql (renamed from app/assets/javascripts/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql)0
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/take_ownership.mutation.graphql12
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql (renamed from app/assets/javascripts/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql)5
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js (renamed from app/assets/javascripts/pipeline_schedules/mount_pipeline_schedules_app.js)2
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js (renamed from app/assets/javascripts/pipeline_schedules/mount_pipeline_schedules_form_app.js)0
-rw-r--r--app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue (renamed from app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/admin_runner_show/index.js (renamed from app/assets/javascripts/runner/admin_runner_show/index.js)0
-rw-r--r--app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue (renamed from app/assets/javascripts/runner/admin_runners/admin_runners_app.vue)12
-rw-r--r--app/assets/javascripts/ci/runner/admin_runners/index.js (renamed from app/assets/javascripts/runner/admin_runners/index.js)2
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/link_cell.vue (renamed from app/assets/javascripts/runner/components/cells/link_cell.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_actions_cell.vue (renamed from app/assets/javascripts/runner/components/cells/runner_actions_cell.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue (renamed from app/assets/javascripts/runner/components/cells/runner_owner_cell.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue (renamed from app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue)2
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue (renamed from app/assets/javascripts/runner/components/cells/runner_status_cell.vue)2
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue (renamed from app/assets/javascripts/runner/components/cells/runner_summary_field.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue (renamed from app/assets/javascripts/runner/components/registration/registration_dropdown.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_token.vue (renamed from app/assets/javascripts/runner/components/registration/registration_token.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue (renamed from app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue)4
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_assigned_item.vue (renamed from app/assets/javascripts/runner/components/runner_assigned_item.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue (renamed from app/assets/javascripts/runner/components/runner_bulk_delete.vue)35
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_bulk_delete_checkbox.vue (renamed from app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_delete_button.vue (renamed from app/assets/javascripts/runner/components/runner_delete_button.vue)11
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_delete_modal.vue (renamed from app/assets/javascripts/runner/components/runner_delete_modal.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_detail.vue (renamed from app/assets/javascripts/runner/components/runner_detail.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_details.vue (renamed from app/assets/javascripts/runner/components/runner_details.vue)6
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_edit_button.vue (renamed from app/assets/javascripts/runner/components/runner_edit_button.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue (renamed from app/assets/javascripts/runner/components/runner_filtered_search_bar.vue)2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_groups.vue (renamed from app/assets/javascripts/runner/components/runner_groups.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_header.vue (renamed from app/assets/javascripts/runner/components/runner_header.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_jobs.vue (renamed from app/assets/javascripts/runner/components/runner_jobs.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_jobs_table.vue (renamed from app/assets/javascripts/runner/components/runner_jobs_table.vue)29
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_list.vue (renamed from app/assets/javascripts/runner/components/runner_list.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue (renamed from app/assets/javascripts/runner/components/runner_list_empty_state.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_membership_toggle.vue (renamed from app/assets/javascripts/runner/components/runner_membership_toggle.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_name.vue (renamed from app/assets/javascripts/runner/components/runner_name.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_pagination.vue (renamed from app/assets/javascripts/runner/components/runner_pagination.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_pause_button.vue (renamed from app/assets/javascripts/runner/components/runner_pause_button.vue)4
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_paused_badge.vue (renamed from app/assets/javascripts/runner/components/runner_paused_badge.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_projects.vue (renamed from app/assets/javascripts/runner/components/runner_projects.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_status_badge.vue (renamed from app/assets/javascripts/runner/components/runner_status_badge.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_status_popover.vue (renamed from app/assets/javascripts/runner/components/runner_status_popover.vue)2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_tag.vue (renamed from app/assets/javascripts/runner/components/runner_tag.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_tags.vue (renamed from app/assets/javascripts/runner/components/runner_tags.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_type_badge.vue (renamed from app/assets/javascripts/runner/components/runner_type_badge.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_type_tabs.vue (renamed from app/assets/javascripts/runner/components/runner_type_tabs.vue)2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_update_form.vue (renamed from app/assets/javascripts/runner/components/runner_update_form.vue)8
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js (renamed from app/assets/javascripts/runner/components/search_tokens/paused_token_config.js)0
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js (renamed from app/assets/javascripts/runner/components/search_tokens/status_token_config.js)8
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue (renamed from app/assets/javascripts/runner/components/search_tokens/tag_token.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js (renamed from app/assets/javascripts/runner/components/search_tokens/tag_token_config.js)0
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/upgrade_status_token_config.js (renamed from app/assets/javascripts/runner/components/search_tokens/upgrade_status_token_config.js)0
-rw-r--r--app/assets/javascripts/ci/runner/components/stat/runner_count.vue (renamed from app/assets/javascripts/runner/components/stat/runner_count.vue)4
-rw-r--r--app/assets/javascripts/ci/runner/components/stat/runner_single_stat.vue (renamed from app/assets/javascripts/runner/components/stat/runner_single_stat.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/components/stat/runner_stats.vue (renamed from app/assets/javascripts/runner/components/stat/runner_stats.vue)4
-rw-r--r--app/assets/javascripts/ci/runner/constants.js (renamed from app/assets/javascripts/runner/constants.js)0
-rw-r--r--app/assets/javascripts/ci/runner/graphql/edit/runner_fields.fragment.graphql (renamed from app/assets/javascripts/runner/graphql/edit/runner_fields.fragment.graphql)0
-rw-r--r--app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql (renamed from app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql)0
-rw-r--r--app/assets/javascripts/ci/runner/graphql/edit/runner_form.query.graphql (renamed from app/assets/javascripts/runner/graphql/edit/runner_form.query.graphql)2
-rw-r--r--app/assets/javascripts/ci/runner/graphql/edit/runner_update.mutation.graphql (renamed from app/assets/javascripts/runner/graphql/edit/runner_update.mutation.graphql)2
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/all_runners.query.graphql (renamed from app/assets/javascripts/runner/graphql/list/all_runners.query.graphql)2
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/all_runners_connection.fragment.graphql (renamed from app/assets/javascripts/runner/graphql/list/all_runners_connection.fragment.graphql)2
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/all_runners_count.query.graphql (renamed from app/assets/javascripts/runner/graphql/list/all_runners_count.query.graphql)0
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/bulk_runner_delete.mutation.graphql (renamed from app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql)0
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/checked_runner_ids.query.graphql (renamed from app/assets/javascripts/runner/graphql/list/checked_runner_ids.query.graphql)0
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/group_runner_connection.fragment.graphql (renamed from app/assets/javascripts/runner/graphql/list/group_runner_connection.fragment.graphql)2
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/group_runners.query.graphql (renamed from app/assets/javascripts/runner/graphql/list/group_runners.query.graphql)2
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/group_runners_count.query.graphql (renamed from app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql)0
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/list_item.fragment.graphql (renamed from app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql)0
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql (renamed from app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql)0
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/local_state.js (renamed from app/assets/javascripts/runner/graphql/list/local_state.js)2
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/runners_registration_token_reset.mutation.graphql (renamed from app/assets/javascripts/runner/graphql/list/runners_registration_token_reset.mutation.graphql)0
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/typedefs.graphql (renamed from app/assets/javascripts/runner/graphql/list/typedefs.graphql)0
-rw-r--r--app/assets/javascripts/ci/runner/graphql/shared/runner_delete.mutation.graphql (renamed from app/assets/javascripts/runner/graphql/shared/runner_delete.mutation.graphql)0
-rw-r--r--app/assets/javascripts/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql (renamed from app/assets/javascripts/runner/graphql/shared/runner_toggle_active.mutation.graphql)0
-rw-r--r--app/assets/javascripts/ci/runner/graphql/show/runner.query.graphql (renamed from app/assets/javascripts/runner/graphql/show/runner.query.graphql)2
-rw-r--r--app/assets/javascripts/ci/runner/graphql/show/runner_details.fragment.graphql (renamed from app/assets/javascripts/runner/graphql/show/runner_details.fragment.graphql)0
-rw-r--r--app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql (renamed from app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql)0
-rw-r--r--app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql (renamed from app/assets/javascripts/runner/graphql/show/runner_jobs.query.graphql)4
-rw-r--r--app/assets/javascripts/ci/runner/graphql/show/runner_projects.query.graphql (renamed from app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql)0
-rw-r--r--app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue (renamed from app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/group_runner_show/index.js (renamed from app/assets/javascripts/runner/group_runner_show/index.js)0
-rw-r--r--app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue (renamed from app/assets/javascripts/runner/group_runners/group_runners_app.vue)19
-rw-r--r--app/assets/javascripts/ci/runner/group_runners/index.js (renamed from app/assets/javascripts/runner/group_runners/index.js)0
-rw-r--r--app/assets/javascripts/ci/runner/local_storage_alert/constants.js (renamed from app/assets/javascripts/runner/local_storage_alert/constants.js)0
-rw-r--r--app/assets/javascripts/ci/runner/local_storage_alert/save_alert_to_local_storage.js (renamed from app/assets/javascripts/runner/local_storage_alert/save_alert_to_local_storage.js)0
-rw-r--r--app/assets/javascripts/ci/runner/local_storage_alert/show_alert_from_local_storage.js (renamed from app/assets/javascripts/runner/local_storage_alert/show_alert_from_local_storage.js)0
-rw-r--r--app/assets/javascripts/ci/runner/runner_edit/index.js (renamed from app/assets/javascripts/runner/runner_edit/index.js)0
-rw-r--r--app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue (renamed from app/assets/javascripts/runner/runner_edit/runner_edit_app.vue)0
-rw-r--r--app/assets/javascripts/ci/runner/runner_search_utils.js (renamed from app/assets/javascripts/runner/runner_search_utils.js)0
-rw-r--r--app/assets/javascripts/ci/runner/runner_update_form_utils.js (renamed from app/assets/javascripts/runner/runner_update_form_utils.js)0
-rw-r--r--app/assets/javascripts/ci/runner/sentry_utils.js (renamed from app/assets/javascripts/runner/sentry_utils.js)0
-rw-r--r--app/assets/javascripts/ci/runner/utils.js (renamed from app/assets/javascripts/runner/utils.js)0
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue139
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue132
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue159
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue62
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue58
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue6
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue232
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue116
-rw-r--r--app/assets/javascripts/ci_variable_list/components/legacy_ci_environments_dropdown.vue81
-rw-r--r--app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue428
-rw-r--r--app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_settings.vue32
-rw-r--r--app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_table.vue199
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql2
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql2
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql2
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql11
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql11
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql11
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql11
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql6
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql6
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/settings.js60
-rw-r--r--app/assets/javascripts/ci_variable_list/index.js62
-rw-r--r--app/assets/javascripts/ci_variable_list/store/actions.js208
-rw-r--r--app/assets/javascripts/ci_variable_list/store/getters.js6
-rw-r--r--app/assets/javascripts/ci_variable_list/store/index.js19
-rw-r--r--app/assets/javascripts/ci_variable_list/store/mutation_types.js33
-rw-r--r--app/assets/javascripts/ci_variable_list/store/mutations.js128
-rw-r--r--app/assets/javascripts/ci_variable_list/store/state.js26
-rw-r--r--app/assets/javascripts/ci_variable_list/store/utils.js45
-rw-r--r--app/assets/javascripts/clusters_list/components/delete_agent_button.vue7
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue10
-rw-r--r--app/assets/javascripts/content_editor/components/suggestions_dropdown.vue8
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_button.vue2
-rw-r--r--app/assets/javascripts/content_editor/content_editor.stories.js1
-rw-r--r--app/assets/javascripts/content_editor/extensions/highlight.js19
-rw-r--r--app/assets/javascripts/content_editor/extensions/html_marks.js1
-rw-r--r--app/assets/javascripts/content_editor/extensions/suggestions.js2
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js2
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js2
-rw-r--r--app/assets/javascripts/cycle_analytics/components/filter_bar.vue13
-rw-r--r--app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue17
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue2
-rw-r--r--app/assets/javascripts/diffs/components/app.vue10
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_row_utils.js25
-rw-r--r--app/assets/javascripts/diffs/store/actions.js14
-rw-r--r--app/assets/javascripts/diffs/store/utils.js11
-rw-r--r--app/assets/javascripts/diffs/utils/tree_worker_utils.js9
-rw-r--r--app/assets/javascripts/dirty_submit/dirty_submit_form.js2
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js58
-rw-r--r--app/assets/javascripts/editor/schema/ci.json214
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue3
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue15
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_to_rollback.query.graphql1
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_user.js3
-rw-r--r--app/assets/javascripts/filtered_search/droplab/plugins/ajax_filter.js3
-rw-r--r--app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js24
-rw-r--r--app/assets/javascripts/flash.js3
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js43
-rw-r--r--app/assets/javascripts/gitlab_version_check.js20
-rw-r--r--app/assets/javascripts/gitlab_version_check/components/gitlab_version_check_badge.vue73
-rw-r--r--app/assets/javascripts/gitlab_version_check/constants.js9
-rw-r--r--app/assets/javascripts/gitlab_version_check/index.js50
-rw-r--r--app/assets/javascripts/gl_field_errors.js11
-rw-r--r--app/assets/javascripts/google_cloud/service_accounts/list.vue12
-rw-r--r--app/assets/javascripts/google_tag_manager/index.js29
-rw-r--r--app/assets/javascripts/graphql_shared/issuable_client.js38
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json7
-rw-r--r--app/assets/javascripts/groups/components/overview_tabs.vue5
-rw-r--r--app/assets/javascripts/groups/components/transfer_group_form.vue52
-rw-r--r--app/assets/javascripts/groups/init_transfer_group_form.js22
-rw-r--r--app/assets/javascripts/groups_projects/components/transfer_locations.vue282
-rw-r--r--app/assets/javascripts/groups_select.js37
-rw-r--r--app/assets/javascripts/ide/components/ide.vue14
-rw-r--r--app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue15
-rw-r--r--app/assets/javascripts/ide/components/panes/right.vue17
-rw-r--r--app/assets/javascripts/ide/components/switch_editors/switch_editors_view.vue103
-rw-r--r--app/assets/javascripts/ide/constants.js1
-rw-r--r--app/assets/javascripts/ide/index.js4
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js2
-rw-r--r--app/assets/javascripts/ide/stores/state.js2
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue19
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/advanced_settings.vue2
-rw-r--r--app/assets/javascripts/import_entities/import_projects/index.js2
-rw-r--r--app/assets/javascripts/integrations/constants.js2
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue19
-rw-r--r--app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue22
-rw-r--r--app/assets/javascripts/integrations/edit/components/trigger_fields.vue4
-rw-r--r--app/assets/javascripts/integrations/edit/index.js4
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue36
-rw-r--r--app/assets/javascripts/invite_members/components/invite_modal_base.vue164
-rw-r--r--app/assets/javascripts/invite_members/components/user_limit_notification.vue105
-rw-r--r--app/assets/javascripts/invite_members/constants.js16
-rw-r--r--app/assets/javascripts/issuable/bulk_update_sidebar/components/graphql/mutations/move_issue.mutation.graphql5
-rw-r--r--app/assets/javascripts/issuable/bulk_update_sidebar/components/move_issues_button.vue171
-rw-r--r--app/assets/javascripts/issuable/bulk_update_sidebar/index.js31
-rw-r--r--app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js33
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue1
-rw-r--r--app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue56
-rw-r--r--app/assets/javascripts/issues/dashboard/index.js25
-rw-r--r--app/assets/javascripts/issues/index.js4
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue56
-rw-r--r--app/assets/javascripts/issues/list/constants.js37
-rw-r--r--app/assets/javascripts/issues/list/graphql.js25
-rw-r--r--app/assets/javascripts/issues/list/index.js27
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues.query.graphql3
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql7
-rw-r--r--app/assets/javascripts/issues/list/utils.js33
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/fields/type.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue5
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/constants.js6
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue29
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue9
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue13
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/duration_cell.vue27
-rw-r--r--app/assets/javascripts/jobs/constants.js1
-rw-r--r--app/assets/javascripts/lazy_loader.js3
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js60
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_action.js57
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js78
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js19
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js12
-rw-r--r--app/assets/javascripts/lib/utils/unit_format/index.js17
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js2
-rw-r--r--app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue9
-rw-r--r--app/assets/javascripts/members/components/members_tabs.vue16
-rw-r--r--app/assets/javascripts/members/index.js2
-rw-r--r--app/assets/javascripts/merge_request_tabs.js41
-rw-r--r--app/assets/javascripts/milestones/milestone_select.js273
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/components/experiment.vue36
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue48
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue7
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue9
-rw-r--r--app/assets/javascripts/notebook/cells/output/markdown.vue42
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue7
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue2
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue6
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue7
-rw-r--r--app/assets/javascripts/notes/components/notes_activity_header.vue2
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue12
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js14
-rw-r--r--app/assets/javascripts/notes/stores/getters.js6
-rw-r--r--app/assets/javascripts/observability/components/observability_app.vue42
-rw-r--r--app/assets/javascripts/observability/index.js28
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue22
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue9
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js18
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue8
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue46
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue61
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue57
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue26
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue112
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js10
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql16
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/index.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue62
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue98
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/forwarding_settings.vue91
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue8
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue190
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/constants.js56
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/fragments/package_settings_fields.fragment.graphql15
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql7
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_package_forwarding_settings.mutation.graphql16
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql7
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/delete_package_modal.vue83
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js6
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue47
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/components/constants.js11
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue31
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/index.js8
-rw-r--r--app/assets/javascripts/pages/admin/runners/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/runners/index/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/runners/show/index.js2
-rw-r--r--app/assets/javascripts/pages/dashboard/issues/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/observability/dashboards/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/observability/explore/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/observability/manage/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/runners/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/runners/index/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/runners/show/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_details.js13
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_tabs.js136
-rw-r--r--app/assets/javascripts/pages/profiles/init_timezone_dropdown.js4
-rw-r--r--app/assets/javascripts/pages/projects/artifacts/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/branches/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/hooks/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/init_blob.js3
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue1
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js4
-rw-r--r--app/assets/javascripts/pages/projects/ml/experiments/show/index.js31
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js11
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue25
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/tree/show/index.js2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue2
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js6
-rw-r--r--app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue1
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/pipeline_schedules.vue134
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue15
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_tabs.vue62
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue16
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue8
-rw-r--r--app/assets/javascripts/pipelines/constants.js3
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js10
-rw-r--r--app/assets/javascripts/pipelines/pipeline_tabs.js15
-rw-r--r--app/assets/javascripts/pipelines/routes.js20
-rw-r--r--app/assets/javascripts/pipelines/utils.js17
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue2
-rw-r--r--app/assets/javascripts/projects/commits/store/actions.js3
-rw-r--r--app/assets/javascripts/projects/compare/components/app.vue4
-rw-r--r--app/assets/javascripts/projects/components/shared/delete_button.vue17
-rw-r--r--app/assets/javascripts/projects/new/components/new_project_push_tip_popover.vue2
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/app.vue26
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/constants.js4
-rw-r--r--app/assets/javascripts/projects/settings/api/access_dropdown_api.js3
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js9
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue43
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/protection.vue14
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue13
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js3
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql24
-rw-r--r--app/assets/javascripts/projects/settings/components/transfer_project_form.vue161
-rw-r--r--app/assets/javascripts/projects/settings/init_transfer_project_form.js6
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/app.vue10
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue62
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql15
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue23
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue56
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_root.vue10
-rw-r--r--app/assets/javascripts/related_issues/constants.js5
-rw-r--r--app/assets/javascripts/related_issues/index.js1
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue4
-rw-r--r--app/assets/javascripts/releases/components/asset_links_form.vue16
-rw-r--r--app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue84
-rw-r--r--app/assets/javascripts/reports/components/issue_body.js2
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue9
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/components/modal.vue74
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue64
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue204
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/actions.js82
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/getters.js13
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/index.js17
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js7
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/mutations.js79
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/state.js71
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/utils.js111
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue2
-rw-r--r--app/assets/javascripts/repository/constants.js4
-rw-r--r--app/assets/javascripts/runner/components/runner_stacked_layout_banner.vue58
-rw-r--r--app/assets/javascripts/search/sidebar/components/app.vue49
-rw-r--r--app/assets/javascripts/search/sidebar/components/results_filters.vue49
-rw-r--r--app/assets/javascripts/search/sidebar/components/scope_navigation.vue66
-rw-r--r--app/assets/javascripts/search/sidebar/constants/index.js11
-rw-r--r--app/assets/javascripts/search/store/actions.js18
-rw-r--r--app/assets/javascripts/search/store/index.js4
-rw-r--r--app/assets/javascripts/search/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/search/store/mutations.js4
-rw-r--r--app/assets/javascripts/search/store/state.js3
-rw-r--r--app/assets/javascripts/self_monitor/components/self_monitor_form.vue16
-rw-r--r--app/assets/javascripts/self_monitor/store/actions.js4
-rw-r--r--app/assets/javascripts/sentry/constants.js43
-rw-r--r--app/assets/javascripts/sentry/sentry_config.js45
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue115
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue35
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers_inputs.vue34
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown.vue252
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue185
-rw-r--r--app/assets/javascripts/sidebar/constants.js11
-rw-r--r--app/assets/javascripts/sidebar/mount_milestone_sidebar.js2
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js344
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js4
-rw-r--r--app/assets/javascripts/token_access/components/token_access.vue6
-rw-r--r--app/assets/javascripts/token_access/components/token_projects_table.vue21
-rw-r--r--app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql4
-rw-r--r--app/assets/javascripts/tracking/tracker.js6
-rw-r--r--app/assets/javascripts/users_select/constants.js11
-rw-r--r--app/assets/javascripts/users_select/index.js214
-rw-r--r--app/assets/javascripts/users_select/utils.js14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue28
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js42
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_container.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/state_container.vue21
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue53
-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_conflicts.vue54
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue26
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue155
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue22
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue44
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue36
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue94
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue83
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue90
-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_details.vue7
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/index.js12
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/router.js21
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/code_block.stories.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js28
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/gitlab_version_check.vue89
-rw-r--r--app/assets/javascripts/vue_shared/components/group_select/constants.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/group_select/group_select.vue195
-rw-r--r--app/assets/javascripts/vue_shared/components/help_popover.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue26
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown_drawer/makrdown_drawer.stories.js54
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue117
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js32
-rw-r--r--app/assets/javascripts/vue_shared/components/namespace_select/namespace_select_deprecated.vue212
-rw-r--r--app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql22
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue90
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js8
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/composer_json_linker.js49
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js22
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemfile_linker.js25
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker.js64
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/podspec_json_linker.js32
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue11
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue8
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/utils.js2
-rw-r--r--app/assets/javascripts/webhooks/components/form_url_app.vue92
-rw-r--r--app/assets/javascripts/webhooks/components/form_url_mask_item.vue46
-rw-r--r--app/assets/javascripts/webhooks/components/push_events.vue112
-rw-r--r--app/assets/javascripts/webhooks/constants.js19
-rw-r--r--app/assets/javascripts/webhooks/index.js2
-rw-r--r--app/assets/javascripts/webhooks/webhook.js23
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue203
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description_rendered.vue117
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue91
-rw-r--r--app/assets/javascripts/work_items/components/work_item_due_date.vue4
-rw-r--r--app/assets/javascripts/work_items/components/work_item_labels.vue22
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js6
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue56
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue192
-rw-r--r--app/assets/javascripts/work_items/components/work_item_milestone.vue37
-rw-r--r--app/assets/javascripts/work_items/constants.js5
-rw-r--r--app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql3
-rw-r--r--app/assets/javascripts/work_items/graphql/milestone.fragment.graphql5
-rw-r--r--app/assets/javascripts/work_items/graphql/project_work_items.query.graphql19
-rw-r--r--app/assets/javascripts/work_items/graphql/typedefs.graphql11
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.query.graphql11
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql12
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_description.subscription.graphql20
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_milestone.subscription.graphql17
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql7
-rw-r--r--app/assets/javascripts/work_items/index.js9
-rw-r--r--app/assets/javascripts/work_items/pages/create_work_item.vue46
-rw-r--r--app/assets/javascripts/work_items/pages/work_item_root.vue8
-rw-r--r--app/assets/javascripts/work_items/router/index.js2
-rw-r--r--app/assets/javascripts/work_items/router/routes.js35
-rw-r--r--app/assets/javascripts/work_items/utils.js6
-rw-r--r--app/assets/javascripts/work_items_hierarchy/components/app.vue2
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss3
-rw-r--r--app/assets/stylesheets/framework/calendar.scss2
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss4
-rw-r--r--app/assets/stylesheets/framework/emojis.scss1
-rw-r--r--app/assets/stylesheets/framework/files.scss4
-rw-r--r--app/assets/stylesheets/framework/layout.scss2
-rw-r--r--app/assets/stylesheets/framework/selects.scss16
-rw-r--r--app/assets/stylesheets/framework/snippets.scss4
-rw-r--r--app/assets/stylesheets/framework/variables.scss95
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss8
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss10
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-dark.scss10
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-light.scss12
-rw-r--r--app/assets/stylesheets/highlight/themes/white.scss11
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss29
-rw-r--r--app/assets/stylesheets/page_bundles/branches.scss (renamed from app/assets/stylesheets/pages/branches.scss)2
-rw-r--r--app/assets/stylesheets/page_bundles/clusters.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/dashboard.scss5
-rw-r--r--app/assets/stylesheets/page_bundles/design_management.scss215
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss5
-rw-r--r--app/assets/stylesheets/page_bundles/issues_show.scss214
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss74
-rw-r--r--app/assets/stylesheets/page_bundles/notifications.scss (renamed from app/assets/stylesheets/pages/notifications.scss)2
-rw-r--r--app/assets/stylesheets/pages/issuable.scss4
-rw-r--r--app/assets/stylesheets/pages/issues.scss10
-rw-r--r--app/assets/stylesheets/pages/ml_experiment_tracking.scss16
-rw-r--r--app/assets/stylesheets/pages/notes.scss6
-rw-r--r--app/assets/stylesheets/pages/projects.scss181
-rw-r--r--app/assets/stylesheets/pages/search.scss7
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss276
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss142
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss94
-rw-r--r--app/assets/stylesheets/themes/_dark.scss28
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss10
-rw-r--r--app/components/pajamas/avatar_component.rb4
-rw-r--r--app/components/pajamas/badge_component.rb4
-rw-r--r--app/components/pajamas/banner_component.rb4
-rw-r--r--app/components/pajamas/spinner_component.html.haml7
-rw-r--r--app/components/pajamas/spinner_component.rb17
-rw-r--r--app/controllers/admin/application_settings_controller.rb6
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb64
-rw-r--r--app/controllers/admin/groups_controller.rb7
-rw-r--r--app/controllers/admin/users_controller.rb33
-rw-r--r--app/controllers/application_controller.rb9
-rw-r--r--app/controllers/concerns/access_tokens_actions.rb16
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb2
-rw-r--r--app/controllers/concerns/integrations/params.rb4
-rw-r--r--app/controllers/concerns/issuable_actions.rb58
-rw-r--r--app/controllers/concerns/issuable_collections_action.rb1
-rw-r--r--app/controllers/concerns/preferred_language_switcher.rb18
-rw-r--r--app/controllers/concerns/preview_markdown.rb2
-rw-r--r--app/controllers/concerns/product_analytics_tracking.rb2
-rw-r--r--app/controllers/concerns/render_access_tokens.rb31
-rw-r--r--app/controllers/concerns/send_file_upload.rb12
-rw-r--r--app/controllers/concerns/verifies_with_email.rb11
-rw-r--r--app/controllers/concerns/web_hooks/hook_actions.rb8
-rw-r--r--app/controllers/concerns/web_hooks/hook_execution_notice.rb2
-rw-r--r--app/controllers/confirmations_controller.rb1
-rw-r--r--app/controllers/dashboard/projects_controller.rb7
-rw-r--r--app/controllers/explore/groups_controller.rb4
-rw-r--r--app/controllers/graphql_controller.rb3
-rw-r--r--app/controllers/groups/boards_controller.rb2
-rw-r--r--app/controllers/groups/dependency_proxy/application_controller.rb5
-rw-r--r--app/controllers/groups/observability_controller.rb32
-rw-r--r--app/controllers/groups/registry/repositories_controller.rb4
-rw-r--r--app/controllers/groups/settings/access_tokens_controller.rb1
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb3
-rw-r--r--app/controllers/groups/settings/packages_and_registries_controller.rb4
-rw-r--r--app/controllers/groups/settings/repository_controller.rb3
-rw-r--r--app/controllers/groups_controller.rb7
-rw-r--r--app/controllers/jira_connect/application_controller.rb26
-rw-r--r--app/controllers/jira_connect/cors_preflight_checks_controller.rb16
-rw-r--r--app/controllers/jira_connect/oauth_application_ids_controller.rb6
-rw-r--r--app/controllers/jira_connect/subscriptions_controller.rb6
-rw-r--r--app/controllers/oauth/authorizations_controller.rb17
-rw-r--r--app/controllers/passwords_controller.rb3
-rw-r--r--app/controllers/profiles/passwords_controller.rb6
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb33
-rw-r--r--app/controllers/projects/alerting/notifications_controller.rb3
-rw-r--r--app/controllers/projects/application_controller.rb6
-rw-r--r--app/controllers/projects/artifacts_controller.rb8
-rw-r--r--app/controllers/projects/boards_controller.rb2
-rw-r--r--app/controllers/projects/commit_controller.rb2
-rw-r--r--app/controllers/projects/environments_controller.rb2
-rw-r--r--app/controllers/projects/google_cloud/configuration_controller.rb2
-rw-r--r--app/controllers/projects/google_cloud/databases_controller.rb9
-rw-r--r--app/controllers/projects/google_cloud/deployments_controller.rb2
-rw-r--r--app/controllers/projects/google_cloud/gcp_regions_controller.rb2
-rw-r--r--app/controllers/projects/google_cloud/service_accounts_controller.rb2
-rw-r--r--app/controllers/projects/graphs_controller.rb2
-rw-r--r--app/controllers/projects/incidents_controller.rb1
-rw-r--r--app/controllers/projects/issues_controller.rb16
-rw-r--r--app/controllers/projects/labels_controller.rb5
-rw-r--r--app/controllers/projects/learn_gitlab_controller.rb1
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb22
-rw-r--r--app/controllers/projects/merge_requests_controller.rb40
-rw-r--r--app/controllers/projects/ml/experiments_controller.rb32
-rw-r--r--app/controllers/projects/notes_controller.rb2
-rw-r--r--app/controllers/projects/packages/infrastructure_registry_controller.rb2
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb2
-rw-r--r--app/controllers/projects/pipelines_controller.rb23
-rw-r--r--app/controllers/projects/product_analytics_controller.rb61
-rw-r--r--app/controllers/projects/prometheus/alerts_controller.rb19
-rw-r--r--app/controllers/projects/registry/repositories_controller.rb10
-rw-r--r--app/controllers/projects/settings/access_tokens_controller.rb1
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb10
-rw-r--r--app/controllers/projects/settings/repository_controller.rb30
-rw-r--r--app/controllers/projects/starrers_controller.rb10
-rw-r--r--app/controllers/projects/templates_controller.rb4
-rw-r--r--app/controllers/projects/usage_quotas_controller.rb2
-rw-r--r--app/controllers/projects/work_items_controller.rb6
-rw-r--r--app/controllers/projects_controller.rb7
-rw-r--r--app/controllers/registrations_controller.rb26
-rw-r--r--app/controllers/search_controller.rb15
-rw-r--r--app/controllers/sessions_controller.rb2
-rw-r--r--app/controllers/terraform/services_controller.rb2
-rw-r--r--app/controllers/users_controller.rb2
-rw-r--r--app/experiments/application_experiment.rb19
-rw-r--r--app/experiments/require_verification_for_namespace_creation_experiment.rb10
-rw-r--r--app/experiments/security_reports_mr_widget_prompt_experiment.rb6
-rw-r--r--app/finders/access_requests_finder.rb2
-rw-r--r--app/finders/autocomplete/users_finder.rb6
-rw-r--r--app/finders/clusters/agent_authorizations_finder.rb30
-rw-r--r--app/finders/clusters/agent_tokens_finder.rb25
-rw-r--r--app/finders/incident_management/timeline_event_tags_finder.rb25
-rw-r--r--app/finders/issuable_finder.rb12
-rw-r--r--app/finders/issues_finder.rb12
-rw-r--r--app/finders/license_template_finder.rb6
-rw-r--r--app/finders/projects_finder.rb7
-rw-r--r--app/finders/users_star_projects_finder.rb2
-rw-r--r--app/finders/work_items/work_items_finder.rb24
-rw-r--r--app/graphql/graphql_triggers.rb4
-rw-r--r--app/graphql/mutations/ci/job/artifacts_destroy.rb2
-rw-r--r--app/graphql/mutations/ci/job/base.rb2
-rw-r--r--app/graphql/mutations/ci/job_artifact/destroy.rb2
-rw-r--r--app/graphql/mutations/ci/pipeline_schedule/take_ownership.rb27
-rw-r--r--app/graphql/mutations/ci/runner/bulk_delete.rb16
-rw-r--r--app/graphql/mutations/ci/runner/update.rb2
-rw-r--r--app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb3
-rw-r--r--app/graphql/mutations/container_repositories/destroy.rb8
-rw-r--r--app/graphql/mutations/incident_management/timeline_event/create.rb4
-rw-r--r--app/graphql/mutations/incident_management/timeline_event_tag/base.rb25
-rw-r--r--app/graphql/mutations/incident_management/timeline_event_tag/create.rb29
-rw-r--r--app/graphql/mutations/work_items/create.rb9
-rw-r--r--app/graphql/mutations/work_items/create_from_task.rb7
-rw-r--r--app/graphql/mutations/work_items/delete.rb7
-rw-r--r--app/graphql/mutations/work_items/delete_task.rb7
-rw-r--r--app/graphql/mutations/work_items/update.rb7
-rw-r--r--app/graphql/mutations/work_items/update_task.rb7
-rw-r--r--app/graphql/resolvers/base_issues_resolver.rb64
-rw-r--r--app/graphql/resolvers/blobs_resolver.rb2
-rw-r--r--app/graphql/resolvers/bulk_labels_resolver.rb2
-rw-r--r--app/graphql/resolvers/concerns/board_item_filterable.rb18
-rw-r--r--app/graphql/resolvers/concerns/issue_resolver_arguments.rb167
-rw-r--r--app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb35
-rw-r--r--app/graphql/resolvers/concerns/issues/sort_arguments.rb26
-rw-r--r--app/graphql/resolvers/concerns/project_search_arguments.rb1
-rw-r--r--app/graphql/resolvers/concerns/search_arguments.rb10
-rw-r--r--app/graphql/resolvers/group_issues_resolver.rb5
-rw-r--r--app/graphql/resolvers/incident_management/timeline_event_tags_resolver.rb15
-rw-r--r--app/graphql/resolvers/incident_management/timeline_events_resolver.rb8
-rw-r--r--app/graphql/resolvers/issue_status_counts_resolver.rb26
-rw-r--r--app/graphql/resolvers/issues/base_parent_resolver.rb48
-rw-r--r--app/graphql/resolvers/issues/base_resolver.rb186
-rw-r--r--app/graphql/resolvers/issues_resolver.rb29
-rw-r--r--app/graphql/resolvers/project_issues_resolver.rb9
-rw-r--r--app/graphql/resolvers/work_item_resolver.rb5
-rw-r--r--app/graphql/resolvers/work_items/types_resolver.rb10
-rw-r--r--app/graphql/resolvers/work_items_resolver.rb5
-rw-r--r--app/graphql/types/base_argument.rb4
-rw-r--r--app/graphql/types/base_enum.rb4
-rw-r--r--app/graphql/types/base_field.rb4
-rw-r--r--app/graphql/types/boards/board_issue_input_type.rb4
-rw-r--r--app/graphql/types/branch_protections/merge_access_level_type.rb2
-rw-r--r--app/graphql/types/branch_protections/push_access_level_type.rb2
-rw-r--r--app/graphql/types/ci/job_need_union.rb5
-rw-r--r--app/graphql/types/commit_signature_interface.rb37
-rw-r--r--app/graphql/types/commit_signatures/gpg_signature_type.rb29
-rw-r--r--app/graphql/types/commit_signatures/verification_status_enum.rb18
-rw-r--r--app/graphql/types/commit_signatures/x509_signature_type.rb22
-rw-r--r--app/graphql/types/commit_type.rb7
-rw-r--r--app/graphql/types/concerns/gitlab_style_deprecations.rb18
-rw-r--r--app/graphql/types/deployment_details_type.rb4
-rw-r--r--app/graphql/types/deployment_type.rb2
-rw-r--r--app/graphql/types/group_type.rb4
-rw-r--r--app/graphql/types/incident_management/timeline_event_tag_type.rb23
-rw-r--r--app/graphql/types/incident_management/timeline_event_type.rb7
-rw-r--r--app/graphql/types/issue_connection.rb15
-rw-r--r--app/graphql/types/issue_type.rb10
-rw-r--r--app/graphql/types/issue_type_enum.rb6
-rw-r--r--app/graphql/types/issues/unioned_issue_filter_input_type.rb16
-rw-r--r--app/graphql/types/merge_request_type.rb5
-rw-r--r--app/graphql/types/metadata_type.rb2
-rw-r--r--app/graphql/types/mutation_type.rb4
-rw-r--r--app/graphql/types/packages/package_base_type.rb2
-rw-r--r--app/graphql/types/packages/package_links_type.rb19
-rw-r--r--app/graphql/types/permission_types/ci/runner.rb2
-rw-r--r--app/graphql/types/project_type.rb25
-rw-r--r--app/graphql/types/projects/branch_rule_type.rb12
-rw-r--r--app/graphql/types/projects/repository_language_type.rb20
-rw-r--r--app/graphql/types/query_type.rb9
-rw-r--r--app/graphql/types/release_links_type.rb10
-rw-r--r--app/graphql/types/release_source_type.rb2
-rw-r--r--app/graphql/types/release_type.rb2
-rw-r--r--app/graphql/types/repository_type.rb2
-rw-r--r--app/graphql/types/subscription_type.rb3
-rw-r--r--app/graphql/types/work_items/widget_interface.rb5
-rw-r--r--app/graphql/types/work_items/widgets/milestone_input_type.rb17
-rw-r--r--app/graphql/types/work_items/widgets/milestone_type.rb23
-rw-r--r--app/graphql/types/x509_certificate_type.rb39
-rw-r--r--app/graphql/types/x509_issuer_type.rb29
-rw-r--r--app/helpers/appearances_helper.rb2
-rw-r--r--app/helpers/application_helper.rb26
-rw-r--r--app/helpers/application_settings_helper.rb8
-rw-r--r--app/helpers/avatars_helper.rb5
-rw-r--r--app/helpers/blob_helper.rb26
-rw-r--r--app/helpers/broadcast_messages_helper.rb5
-rw-r--r--app/helpers/diff_helper.rb18
-rw-r--r--app/helpers/events_helper.rb2
-rw-r--r--app/helpers/form_helper.rb9
-rw-r--r--app/helpers/gitlab_routing_helper.rb1
-rw-r--r--app/helpers/graph_helper.rb6
-rw-r--r--app/helpers/groups/group_members_helper.rb7
-rw-r--r--app/helpers/groups/observability_helper.rb50
-rw-r--r--app/helpers/groups_helper.rb27
-rw-r--r--app/helpers/hooks_helper.rb2
-rw-r--r--app/helpers/icons_helper.rb22
-rw-r--r--app/helpers/ide_helper.rb2
-rw-r--r--app/helpers/integrations_helper.rb25
-rw-r--r--app/helpers/issuables_helper.rb15
-rw-r--r--app/helpers/issues_helper.rb6
-rw-r--r--app/helpers/json_helper.rb14
-rw-r--r--app/helpers/markup_helper.rb93
-rw-r--r--app/helpers/merge_requests_helper.rb6
-rw-r--r--app/helpers/nav/top_nav_helper.rb78
-rw-r--r--app/helpers/projects/alert_management_helper.rb1
-rw-r--r--app/helpers/projects/ml/experiments_helper.rb24
-rw-r--r--app/helpers/projects/pipeline_helper.rb1
-rw-r--r--app/helpers/projects/project_members_helper.rb3
-rw-r--r--app/helpers/projects_helper.rb12
-rw-r--r--app/helpers/recaptcha_helper.rb14
-rw-r--r--app/helpers/reminder_emails_helper.rb2
-rw-r--r--app/helpers/routing/packages_helper.rb9
-rw-r--r--app/helpers/routing/projects_helper.rb13
-rw-r--r--app/helpers/routing/pseudonymization_helper.rb7
-rw-r--r--app/helpers/search_helper.rb82
-rw-r--r--app/helpers/selects_helper.rb40
-rw-r--r--app/helpers/todos_helper.rb42
-rw-r--r--app/mailers/emails/identity_verification.rb16
-rw-r--r--app/mailers/emails/releases.rb2
-rw-r--r--app/mailers/previews/notify_preview.rb2
-rw-r--r--app/models/active_session.rb40
-rw-r--r--app/models/alert_management/http_integration.rb3
-rw-r--r--app/models/appearance.rb20
-rw-r--r--app/models/application_setting.rb22
-rw-r--r--app/models/application_setting_implementation.rb2
-rw-r--r--app/models/awareness_session.rb49
-rw-r--r--app/models/broadcast_message.rb4
-rw-r--r--app/models/ci/bridge.rb33
-rw-r--r--app/models/ci/build.rb95
-rw-r--r--app/models/ci/build_metadata.rb16
-rw-r--r--app/models/ci/build_trace_chunk.rb2
-rw-r--r--app/models/ci/group_variable.rb1
-rw-r--r--app/models/ci/instance_variable.rb1
-rw-r--r--app/models/ci/job_variable.rb1
-rw-r--r--app/models/ci/pipeline.rb8
-rw-r--r--app/models/ci/pipeline_metadata.rb2
-rw-r--r--app/models/ci/pipeline_schedule_variable.rb1
-rw-r--r--app/models/ci/pipeline_variable.rb1
-rw-r--r--app/models/ci/processable.rb1
-rw-r--r--app/models/ci/secure_file.rb2
-rw-r--r--app/models/ci/variable.rb1
-rw-r--r--app/models/clusters/applications/cert_manager.rb11
-rw-r--r--app/models/clusters/applications/crossplane.rb7
-rw-r--r--app/models/clusters/applications/helm.rb2
-rw-r--r--app/models/clusters/applications/ingress.rb5
-rw-r--r--app/models/clusters/applications/jupyter.rb2
-rw-r--r--app/models/clusters/applications/knative.rb2
-rw-r--r--app/models/clusters/applications/prometheus.rb8
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/clusters/cluster.rb2
-rw-r--r--app/models/clusters/integrations/prometheus.rb6
-rw-r--r--app/models/clusters/platforms/kubernetes.rb30
-rw-r--r--app/models/clusters/providers/aws.rb6
-rw-r--r--app/models/clusters/providers/gcp.rb8
-rw-r--r--app/models/commit_collection.rb7
-rw-r--r--app/models/commit_signatures/gpg_signature.rb4
-rw-r--r--app/models/commit_status.rb13
-rw-r--r--app/models/concerns/ci/has_status.rb4
-rw-r--r--app/models/concerns/ci/metadatable.rb10
-rw-r--r--app/models/concerns/ci/partitionable.rb9
-rw-r--r--app/models/concerns/ci/partitionable/switch.rb57
-rw-r--r--app/models/concerns/ci/raw_variable.rb17
-rw-r--r--app/models/concerns/ci/track_environment_usage.rb2
-rw-r--r--app/models/concerns/encrypted_user_password.rb82
-rw-r--r--app/models/concerns/enums/sbom.rb15
-rw-r--r--app/models/concerns/file_store_mounter.rb34
-rw-r--r--app/models/concerns/issuable.rb17
-rw-r--r--app/models/concerns/milestoneable.rb2
-rw-r--r--app/models/concerns/mirror_authentication.rb2
-rw-r--r--app/models/concerns/noteable.rb22
-rw-r--r--app/models/concerns/packages/debian/distribution.rb9
-rw-r--r--app/models/concerns/pg_full_text_searchable.rb47
-rw-r--r--app/models/concerns/project_features_compatibility.rb4
-rw-r--r--app/models/concerns/protected_ref.rb1
-rw-r--r--app/models/concerns/protected_ref_access.rb4
-rw-r--r--app/models/concerns/redis_cacheable.rb10
-rw-r--r--app/models/concerns/repository_storage_movable.rb4
-rw-r--r--app/models/concerns/subquery.rb24
-rw-r--r--app/models/concerns/ttl_expirable.rb2
-rw-r--r--app/models/container_repository.rb23
-rw-r--r--app/models/cycle_analytics/project_level_stage_adapter.rb2
-rw-r--r--app/models/dependency_proxy/group_setting.rb4
-rw-r--r--app/models/deploy_token.rb2
-rw-r--r--app/models/deployment.rb5
-rw-r--r--app/models/diff_discussion.rb8
-rw-r--r--app/models/diff_viewer/server_side.rb8
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb11
-rw-r--r--app/models/event.rb4
-rw-r--r--app/models/event_collection.rb25
-rw-r--r--app/models/experiment.rb77
-rw-r--r--app/models/experiment_subject.rb32
-rw-r--r--app/models/experiment_user.rb14
-rw-r--r--app/models/group.rb21
-rw-r--r--app/models/hooks/active_hook_filter.rb28
-rw-r--r--app/models/hooks/system_hook.rb6
-rw-r--r--app/models/hooks/web_hook.rb48
-rw-r--r--app/models/hooks/web_hook_log.rb2
-rw-r--r--app/models/incident_management/timeline_event.rb2
-rw-r--r--app/models/incident_management/timeline_event_tag.rb11
-rw-r--r--app/models/instance_metadata.rb5
-rw-r--r--app/models/integration.rb32
-rw-r--r--app/models/integrations/assembla.rb5
-rw-r--r--app/models/integrations/bamboo.rb9
-rw-r--r--app/models/integrations/base_chat_notification.rb8
-rw-r--r--app/models/integrations/base_ci.rb2
-rw-r--r--app/models/integrations/base_issue_tracker.rb2
-rw-r--r--app/models/integrations/base_monitoring.rb2
-rw-r--r--app/models/integrations/base_slack_notification.rb62
-rw-r--r--app/models/integrations/base_slash_commands.rb2
-rw-r--r--app/models/integrations/base_third_party_wiki.rb2
-rw-r--r--app/models/integrations/buildkite.rb2
-rw-r--r--app/models/integrations/chat_message/pipeline_message.rb9
-rw-r--r--app/models/integrations/datadog.rb7
-rw-r--r--app/models/integrations/discord.rb3
-rw-r--r--app/models/integrations/drone_ci.rb1
-rw-r--r--app/models/integrations/hangouts_chat.rb6
-rw-r--r--app/models/integrations/jenkins.rb20
-rw-r--r--app/models/integrations/jira.rb6
-rw-r--r--app/models/integrations/mattermost.rb3
-rw-r--r--app/models/integrations/microsoft_teams.rb6
-rw-r--r--app/models/integrations/packagist.rb7
-rw-r--r--app/models/integrations/pivotaltracker.rb2
-rw-r--r--app/models/integrations/pumble.rb4
-rw-r--r--app/models/integrations/slack.rb63
-rw-r--r--app/models/integrations/teamcity.rb9
-rw-r--r--app/models/integrations/unify_circuit.rb8
-rw-r--r--app/models/integrations/webex_teams.rb4
-rw-r--r--app/models/issue.rb41
-rw-r--r--app/models/iteration.rb3
-rw-r--r--app/models/label.rb3
-rw-r--r--app/models/member.rb2
-rw-r--r--app/models/members/group_member.rb2
-rw-r--r--app/models/members/last_group_owner_assigner.rb2
-rw-r--r--app/models/members/member_task.rb5
-rw-r--r--app/models/members/project_member.rb2
-rw-r--r--app/models/merge_request.rb22
-rw-r--r--app/models/merge_request_assignee.rb3
-rw-r--r--app/models/merge_request_diff.rb16
-rw-r--r--app/models/merge_request_diff_commit.rb2
-rw-r--r--app/models/merge_request_diff_file.rb7
-rw-r--r--app/models/merge_request_reviewer.rb3
-rw-r--r--app/models/ml/candidate.rb9
-rw-r--r--app/models/ml/candidate_metric.rb2
-rw-r--r--app/models/ml/experiment.rb2
-rw-r--r--app/models/namespace.rb14
-rw-r--r--app/models/namespace_setting.rb5
-rw-r--r--app/models/network/graph.rb2
-rw-r--r--app/models/note.rb13
-rw-r--r--app/models/notification_setting.rb4
-rw-r--r--app/models/oauth_access_token.rb4
-rw-r--r--app/models/operations/feature_flag.rb4
-rw-r--r--app/models/packages/go/module_version.rb5
-rw-r--r--app/models/pages_domain.rb12
-rw-r--r--app/models/personal_access_token.rb33
-rw-r--r--app/models/postgresql/detached_partition.rb4
-rw-r--r--app/models/preloaders/project_root_ancestor_preloader.rb2
-rw-r--r--app/models/preloaders/user_max_access_level_in_projects_preloader.rb6
-rw-r--r--app/models/project.rb31
-rw-r--r--app/models/project_authorization.rb10
-rw-r--r--app/models/project_ci_cd_setting.rb4
-rw-r--r--app/models/project_feature.rb1
-rw-r--r--app/models/project_setting.rb6
-rw-r--r--app/models/project_statistics.rb7
-rw-r--r--app/models/projects/import_export/relation_export.rb15
-rw-r--r--app/models/projects/wiki_repository.rb13
-rw-r--r--app/models/protected_branch.rb16
-rw-r--r--app/models/protected_tag.rb1
-rw-r--r--app/models/repository.rb7
-rw-r--r--app/models/serverless/domain_cluster.rb8
-rw-r--r--app/models/terraform/state.rb2
-rw-r--r--app/models/terraform/state_version.rb2
-rw-r--r--app/models/time_tracking/timelog_category.rb3
-rw-r--r--app/models/todo.rb2
-rw-r--r--app/models/user.rb73
-rw-r--r--app/models/users/callout.rb3
-rw-r--r--app/models/users/ghost_user_migration.rb2
-rw-r--r--app/models/users/namespace_commit_email.rb14
-rw-r--r--app/models/users_star_project.rb16
-rw-r--r--app/models/wiki.rb108
-rw-r--r--app/models/work_item.rb10
-rw-r--r--app/models/work_items/type.rb15
-rw-r--r--app/models/work_items/widgets/hierarchy.rb4
-rw-r--r--app/models/work_items/widgets/milestone.rb9
-rw-r--r--app/policies/blob_policy.rb2
-rw-r--r--app/policies/ci/build_policy.rb8
-rw-r--r--app/policies/commit_policy.rb2
-rw-r--r--app/policies/commit_signatures/gpg_signature_policy.rb7
-rw-r--r--app/policies/commit_signatures/x509_commit_signature_policy.rb7
-rw-r--r--app/policies/concerns/member_policy_helpers.rb19
-rw-r--r--app/policies/global_policy.rb2
-rw-r--r--app/policies/group_member_policy.rb14
-rw-r--r--app/policies/group_policy.rb7
-rw-r--r--app/policies/incident_management/timeline_event_tag_policy.rb7
-rw-r--r--app/policies/issuable_policy.rb2
-rw-r--r--app/policies/note_policy.rb2
-rw-r--r--app/policies/packages/policies/project_policy.rb2
-rw-r--r--app/policies/project_member_policy.rb15
-rw-r--r--app/policies/project_policy.rb31
-rw-r--r--app/policies/user_policy.rb1
-rw-r--r--app/presenters/blob_presenter.rb30
-rw-r--r--app/presenters/ci/build_runner_presenter.rb8
-rw-r--r--app/presenters/ci/pipeline_presenter.rb2
-rw-r--r--app/presenters/commit_status_presenter.rb2
-rw-r--r--app/presenters/deployments/deployment_presenter.rb7
-rw-r--r--app/presenters/release_presenter.rb10
-rw-r--r--app/serializers/ci/pipeline_entity.rb6
-rw-r--r--app/serializers/codequality_degradation_entity.rb2
-rw-r--r--app/serializers/detailed_status_entity.rb37
-rw-r--r--app/serializers/diff_file_entity.rb21
-rw-r--r--app/serializers/diffs_entity.rb2
-rw-r--r--app/serializers/diffs_metadata_entity.rb2
-rw-r--r--app/serializers/environment_serializer.rb8
-rw-r--r--app/serializers/group_child_serializer.rb5
-rw-r--r--app/serializers/integrations/event_entity.rb3
-rw-r--r--app/serializers/integrations/field_entity.rb2
-rw-r--r--app/serializers/merge_request_noteable_entity.rb2
-rw-r--r--app/serializers/merge_request_poll_cached_widget_entity.rb9
-rw-r--r--app/serializers/merge_request_poll_widget_entity.rb10
-rw-r--r--app/serializers/merge_requests/pipeline_entity.rb9
-rw-r--r--app/serializers/paginated_diff_entity.rb2
-rw-r--r--app/serializers/pipeline_serializer.rb1
-rw-r--r--app/serializers/project_entity.rb8
-rw-r--r--app/serializers/project_import_entity.rb10
-rw-r--r--app/serializers/test_case_entity.rb23
-rw-r--r--app/serializers/test_report_entity.rb14
-rw-r--r--app/serializers/test_report_summary_entity.rb2
-rw-r--r--app/serializers/test_suite_entity.rb19
-rw-r--r--app/serializers/test_suite_summary_entity.rb5
-rw-r--r--app/services/bulk_imports/create_pipeline_trackers_service.rb2
-rw-r--r--app/services/bulk_imports/lfs_objects_export_service.rb2
-rw-r--r--app/services/ci/after_requeue_job_service.rb20
-rw-r--r--app/services/ci/archive_trace_service.rb2
-rw-r--r--app/services/ci/build_erase_service.rb4
-rw-r--r--app/services/ci/create_pipeline_service.rb12
-rw-r--r--app/services/ci/job_artifacts/destroy_associations_service.rb2
-rw-r--r--app/services/ci/job_artifacts/destroy_batch_service.rb59
-rw-r--r--app/services/ci/job_artifacts/track_artifact_report_service.rb2
-rw-r--r--app/services/ci/list_config_variables_service.rb2
-rw-r--r--app/services/ci/pipeline_artifacts/coverage_report_service.rb2
-rw-r--r--app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb4
-rw-r--r--app/services/ci/pipeline_schedules/take_ownership_service.rb34
-rw-r--r--app/services/ci/pipeline_trigger_service.rb2
-rw-r--r--app/services/ci/play_build_service.rb24
-rw-r--r--app/services/ci/process_build_service.rb4
-rw-r--r--app/services/ci/register_job_service.rb4
-rw-r--r--app/services/ci/retry_job_service.rb16
-rw-r--r--app/services/ci/runners/bulk_delete_runners_service.rb54
-rw-r--r--app/services/ci/runners/set_runner_associated_projects_service.rb2
-rw-r--r--app/services/clusters/applications/check_ingress_ip_address_service.rb46
-rw-r--r--app/services/clusters/applications/check_installation_progress_service.rb42
-rw-r--r--app/services/clusters/applications/check_uninstall_progress_service.rb42
-rw-r--r--app/services/clusters/applications/check_upgrade_progress_service.rb71
-rw-r--r--app/services/clusters/applications/create_service.rb18
-rw-r--r--app/services/clusters/applications/patch_service.rb32
-rw-r--r--app/services/clusters/applications/prometheus_update_service.rb38
-rw-r--r--app/services/clusters/applications/update_service.rb17
-rw-r--r--app/services/clusters/kubernetes/configure_istio_ingress_service.rb112
-rw-r--r--app/services/concerns/alert_management/responses.rb4
-rw-r--r--app/services/dependency_proxy/find_cached_manifest_service.rb1
-rw-r--r--app/services/deployments/create_for_build_service.rb66
-rw-r--r--app/services/environments/create_for_build_service.rb40
-rw-r--r--app/services/environments/schedule_to_delete_review_apps_service.rb2
-rw-r--r--app/services/event_create_service.rb67
-rw-r--r--app/services/git/base_hooks_service.rb14
-rw-r--r--app/services/google_cloud/create_service_accounts_service.rb4
-rw-r--r--app/services/google_cloud/generate_pipeline_service.rb7
-rw-r--r--app/services/google_cloud/setup_cloudsql_instance_service.rb8
-rw-r--r--app/services/groups/create_service.rb11
-rw-r--r--app/services/groups/update_service.rb4
-rw-r--r--app/services/incident_management/timeline_event_tags/base_service.rb27
-rw-r--r--app/services/incident_management/timeline_event_tags/create_service.rb32
-rw-r--r--app/services/incident_management/timeline_events/create_service.rb66
-rw-r--r--app/services/incident_management/timeline_events/update_service.rb9
-rw-r--r--app/services/issuable/bulk_update_service.rb5
-rw-r--r--app/services/issuable/discussions_list_service.rb70
-rw-r--r--app/services/issues/update_service.rb1
-rw-r--r--app/services/jira_import/start_import_service.rb2
-rw-r--r--app/services/labels/transfer_service.rb4
-rw-r--r--app/services/loose_foreign_keys/process_deleted_records_service.rb18
-rw-r--r--app/services/markup/rendering_service.rb79
-rw-r--r--app/services/members/approve_access_request_service.rb6
-rw-r--r--app/services/members/destroy_service.rb12
-rw-r--r--app/services/members/update_service.rb89
-rw-r--r--app/services/merge_requests/add_context_service.rb2
-rw-r--r--app/services/merge_requests/after_create_service.rb2
-rw-r--r--app/services/merge_requests/approval_service.rb2
-rw-r--r--app/services/merge_requests/base_service.rb6
-rw-r--r--app/services/merge_requests/create_service.rb21
-rw-r--r--app/services/merge_requests/mergeability/run_checks_service.rb3
-rw-r--r--app/services/merge_requests/mergeability_check_service.rb3
-rw-r--r--app/services/merge_requests/remove_approval_service.rb2
-rw-r--r--app/services/merge_requests/update_assignees_service.rb13
-rw-r--r--app/services/metrics/dashboard/self_monitoring_dashboard_service.rb2
-rw-r--r--app/services/milestones/transfer_service.rb2
-rw-r--r--app/services/namespaces/statistics_refresher_service.rb1
-rw-r--r--app/services/notes/create_service.rb2
-rw-r--r--app/services/notification_service.rb10
-rw-r--r--app/services/packages/debian/create_distribution_service.rb2
-rw-r--r--app/services/packages/debian/update_distribution_service.rb2
-rw-r--r--app/services/packages/maven/metadata/base_create_xml_service.rb5
-rw-r--r--app/services/packages/maven/metadata/create_versions_xml_service.rb15
-rw-r--r--app/services/packages/npm/create_package_service.rb2
-rw-r--r--app/services/packages/rpm/parse_package_service.rb3
-rw-r--r--app/services/packages/rpm/repository_metadata/base_builder.rb46
-rw-r--r--app/services/packages/rpm/repository_metadata/build_filelist_xml.rb14
-rw-r--r--app/services/packages/rpm/repository_metadata/build_filelist_xml_service.rb39
-rw-r--r--app/services/packages/rpm/repository_metadata/build_other_xml.rb14
-rw-r--r--app/services/packages/rpm/repository_metadata/build_other_xml_service.rb31
-rw-r--r--app/services/packages/rpm/repository_metadata/build_primary_xml_service.rb (renamed from app/services/packages/rpm/repository_metadata/build_primary_xml.rb)28
-rw-r--r--app/services/packages/rpm/repository_metadata/build_repomd_xml_service.rb (renamed from app/services/packages/rpm/repository_metadata/build_repomd_xml.rb)14
-rw-r--r--app/services/packages/rpm/repository_metadata/build_xml_base_service.rb22
-rw-r--r--app/services/packages/rpm/repository_metadata/update_xml_service.rb62
-rw-r--r--app/services/personal_access_tokens/revoke_service.rb2
-rw-r--r--app/services/projects/lfs_pointers/lfs_download_link_list_service.rb6
-rw-r--r--app/services/projects/move_users_star_projects_service.rb4
-rw-r--r--app/services/projects/prometheus/alerts/notify_service.rb10
-rw-r--r--app/services/projects/unlink_fork_service.rb2
-rw-r--r--app/services/protected_branches/api_service.rb33
-rw-r--r--app/services/protected_branches/cache_service.rb29
-rw-r--r--app/services/protected_refs/access_level_params.rb4
-rw-r--r--app/services/resource_events/base_change_timebox_service.rb4
-rw-r--r--app/services/resource_events/change_milestone_service.rb6
-rw-r--r--app/services/service_ping/submit_service.rb2
-rw-r--r--app/services/snippets/create_service.rb2
-rw-r--r--app/services/spam/spam_verdict_service.rb2
-rw-r--r--app/services/system_notes/issuables_service.rb8
-rw-r--r--app/services/tags/create_service.rb2
-rw-r--r--app/services/todo_service.rb7
-rw-r--r--app/services/two_factor/base_service.rb4
-rw-r--r--app/services/two_factor/destroy_service.rb6
-rw-r--r--app/services/user_project_access_changed_service.rb5
-rw-r--r--app/services/users/build_service.rb5
-rw-r--r--app/services/users/destroy_service.rb65
-rw-r--r--app/services/users/migrate_records_to_ghost_user_in_batches_service.rb25
-rw-r--r--app/services/users/migrate_to_ghost_user_service.rb113
-rw-r--r--app/services/web_hook_service.rb15
-rw-r--r--app/services/work_items/create_service.rb7
-rw-r--r--app/services/work_items/widgets/hierarchy_service/base_service.rb11
-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/uploaders/object_storage/cdn.rb10
-rw-r--r--app/uploaders/object_storage/cdn/google_cdn.rb18
-rw-r--r--app/validators/branch_filter_validator.rb37
-rw-r--r--app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json305
-rw-r--r--app/validators/json_schemas/web_hooks_url_variables.json2
-rw-r--r--app/validators/nested_attributes_duplicates_validator.rb10
-rw-r--r--app/validators/web_hooks/wildcard_branch_filter_validator.rb34
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml2
-rw-r--r--app/views/admin/application_settings/_jira_connect.html.haml (renamed from app/views/admin/application_settings/_jira_connect_application_key.html.haml)6
-rw-r--r--app/views/admin/application_settings/_package_registry.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_check.html.haml4
-rw-r--r--app/views/admin/application_settings/_user_restrictions.html.haml2
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml3
-rw-r--r--app/views/admin/application_settings/appearances/_form.html.haml2
-rw-r--r--app/views/admin/application_settings/general.html.haml3
-rw-r--r--app/views/admin/application_settings/service_usage_data.html.haml2
-rw-r--r--app/views/admin/dashboard/index.html.haml2
-rw-r--r--app/views/admin/groups/show.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/_projects.html.haml4
-rw-r--r--app/views/admin/users/_users.html.haml2
-rw-r--r--app/views/award_emoji/_awards_block.html.haml2
-rw-r--r--app/views/ci/variables/_index.html.haml2
-rw-r--r--app/views/ci/variables/_url_query_variable_row.html.haml28
-rw-r--r--app/views/dashboard/_groups_head.html.haml2
-rw-r--r--app/views/dashboard/issues.html.haml25
-rw-r--r--app/views/dashboard/todos/_todo.html.haml3
-rw-r--r--app/views/devise/confirmations/almost_there.haml2
-rw-r--r--app/views/devise/sessions/new.html.haml3
-rw-r--r--app/views/doorkeeper/authorizations/forbidden.html.haml5
-rw-r--r--app/views/errors/request_conflict.html.haml2
-rw-r--r--app/views/groups/_archived_projects.html.haml7
-rw-r--r--app/views/groups/_shared_projects.html.haml7
-rw-r--r--app/views/groups/_subgroups_and_projects.html.haml4
-rw-r--r--app/views/groups/group_members/index.html.haml2
-rw-r--r--app/views/groups/observability/index.html.haml2
-rw-r--r--app/views/groups/observability/observability.html.haml3
-rw-r--r--app/views/groups/settings/_remove.html.haml2
-rw-r--r--app/views/groups/settings/_transfer.html.haml2
-rw-r--r--app/views/groups/settings/access_tokens/index.html.haml3
-rw-r--r--app/views/groups/show.html.haml35
-rw-r--r--app/views/help/index.html.haml2
-rw-r--r--app/views/import/_githubish_status.html.haml2
-rw-r--r--app/views/layouts/_img_loader.html.haml3
-rw-r--r--app/views/layouts/_page.html.haml2
-rw-r--r--app/views/layouts/component_preview.html.haml17
-rw-r--r--app/views/layouts/group.html.haml2
-rw-r--r--app/views/layouts/header/_default.html.haml64
-rw-r--r--app/views/layouts/header/_gitlab_version.html.haml13
-rw-r--r--app/views/layouts/header/_new_dropdown.html.haml2
-rw-r--r--app/views/layouts/project.html.haml1
-rw-r--r--app/views/notify/pipeline_failed_email.html.haml23
-rw-r--r--app/views/notify/unknown_sign_in_email.html.haml2
-rw-r--r--app/views/profiles/keys/_form.html.haml6
-rw-r--r--app/views/profiles/notifications/show.html.haml1
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml2
-rw-r--r--app/views/profiles/show.html.haml2
-rw-r--r--app/views/projects/_files.html.haml2
-rw-r--r--app/views/projects/_import_project_pane.html.haml55
-rw-r--r--app/views/projects/_last_push.html.haml2
-rw-r--r--app/views/projects/_merge_request_merge_checks_settings.html.haml1
-rw-r--r--app/views/projects/_transfer.html.haml3
-rw-r--r--app/views/projects/_visibility_modal.html.haml29
-rw-r--r--app/views/projects/artifacts/_artifact.html.haml61
-rw-r--r--app/views/projects/artifacts/_table.html.haml16
-rw-r--r--app/views/projects/artifacts/index.html.haml13
-rw-r--r--app/views/projects/blob/_blob.html.haml2
-rw-r--r--app/views/projects/blob/_editor.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_loading.html.haml2
-rw-r--r--app/views/projects/branch_defaults/_branch_names_fields.html.haml14
-rw-r--r--app/views/projects/branch_defaults/_default_branch_fields.html.haml16
-rw-r--r--app/views/projects/branch_defaults/_show.html.haml17
-rw-r--r--app/views/projects/branches/_branch.html.haml13
-rw-r--r--app/views/projects/branches/_panel.html.haml2
-rw-r--r--app/views/projects/branches/index.html.haml16
-rw-r--r--app/views/projects/buttons/_download.html.haml2
-rw-r--r--app/views/projects/commit/_commit_box.html.haml2
-rw-r--r--app/views/projects/commits/_commit.html.haml4
-rw-r--r--app/views/projects/commits/_commits.html.haml7
-rw-r--r--app/views/projects/default_branch/_show.html.haml29
-rw-r--r--app/views/projects/deployments/_actions.haml4
-rw-r--r--app/views/projects/deployments/_rollback.haml2
-rw-r--r--app/views/projects/empty.html.haml4
-rw-r--r--app/views/projects/hooks/edit.html.haml2
-rw-r--r--app/views/projects/hooks/index.html.haml4
-rw-r--r--app/views/projects/incidents/show.html.haml1
-rw-r--r--app/views/projects/issues/_related_issues.html.haml3
-rw-r--r--app/views/projects/issues/_work_item_links.html.haml3
-rw-r--r--app/views/projects/issues/show.html.haml1
-rw-r--r--app/views/projects/merge_requests/_awards_block.html.haml2
-rw-r--r--app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml8
-rw-r--r--app/views/projects/merge_requests/_code_dropdown.html.haml6
-rw-r--r--app/views/projects/merge_requests/_commits.html.haml5
-rw-r--r--app/views/projects/merge_requests/_mr_box.html.haml2
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml4
-rw-r--r--app/views/projects/merge_requests/creations/_new_submit.html.haml8
-rw-r--r--app/views/projects/merge_requests/dropdowns/_branch.html.haml2
-rw-r--r--app/views/projects/merge_requests/dropdowns/_project.html.haml2
-rw-r--r--app/views/projects/merge_requests/show.html.haml5
-rw-r--r--app/views/projects/milestones/_form.html.haml4
-rw-r--r--app/views/projects/mirrors/_authentication_method.html.haml4
-rw-r--r--app/views/projects/mirrors/_disabled_mirror_badge.html.haml2
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml4
-rw-r--r--app/views/projects/mirrors/_mirror_repos_form.html.haml2
-rw-r--r--app/views/projects/mirrors/_mirror_repos_list.html.haml10
-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.haml11
-rw-r--r--app/views/projects/ml/experiments/show.html.haml14
-rw-r--r--app/views/projects/network/show.html.haml3
-rw-r--r--app/views/projects/no_repo.html.haml2
-rw-r--r--app/views/projects/notes/_actions.html.haml8
-rw-r--r--app/views/projects/pipeline_schedules/_form.html.haml8
-rw-r--r--app/views/projects/pipelines/_info.html.haml29
-rw-r--r--app/views/projects/pipelines/show.html.haml2
-rw-r--r--app/views/projects/product_analytics/_graph.html.haml6
-rw-r--r--app/views/projects/product_analytics/_links.html.haml5
-rw-r--r--app/views/projects/product_analytics/_tracker.html.erb10
-rw-r--r--app/views/projects/product_analytics/graphs.html.haml16
-rw-r--r--app/views/projects/product_analytics/index.html.haml16
-rw-r--r--app/views/projects/product_analytics/setup.html.haml12
-rw-r--r--app/views/projects/product_analytics/test.html.haml17
-rw-r--r--app/views/projects/project_members/index.html.haml1
-rw-r--r--app/views/projects/protected_branches/shared/_dropdown.html.haml8
-rw-r--r--app/views/projects/protected_branches/shared/_index.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_dropdown.html.haml8
-rw-r--r--app/views/projects/protected_tags/shared/_tags_list.html.haml13
-rw-r--r--app/views/projects/settings/_archive.html.haml2
-rw-r--r--app/views/projects/settings/access_tokens/index.html.haml2
-rw-r--r--app/views/projects/settings/branch_rules/index.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml6
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml2
-rw-r--r--app/views/projects/settings/repository/show.html.haml2
-rw-r--r--app/views/projects/show.html.haml1
-rw-r--r--app/views/projects/snippets/show.html.haml4
-rw-r--r--app/views/projects/triggers/_form.html.haml4
-rw-r--r--app/views/registrations/welcome/show.html.haml1
-rw-r--r--app/views/search/_category.html.haml2
-rw-r--r--app/views/search/_results.html.haml3
-rw-r--r--app/views/search/_results_status.html.haml5
-rw-r--r--app/views/search/results/_blob_highlight.html.haml3
-rw-r--r--app/views/shared/_file_picker_button.html.haml3
-rw-r--r--app/views/shared/_flash_user_callout.html.haml2
-rw-r--r--app/views/shared/_label.html.haml6
-rw-r--r--app/views/shared/_md_preview.html.haml2
-rw-r--r--app/views/shared/access_tokens/_created_container.html.haml12
-rw-r--r--app/views/shared/access_tokens/_table.html.haml51
-rw-r--r--app/views/shared/deploy_tokens/_form.html.haml2
-rw-r--r--app/views/shared/deploy_tokens/_index.html.haml21
-rw-r--r--app/views/shared/empty_states/_snippets.html.haml4
-rw-r--r--app/views/shared/issuable/_bulk_update_sidebar.html.haml6
-rw-r--r--app/views/shared/issuable/_form.html.haml5
-rw-r--r--app/views/shared/issuable/_milestone_dropdown.html.haml24
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml32
-rw-r--r--app/views/shared/issuable/_sidebar_assignees.html.haml4
-rw-r--r--app/views/shared/issuable/_sidebar_reviewers.html.haml12
-rw-r--r--app/views/shared/issuable/form/_metadata.html.haml3
-rw-r--r--app/views/shared/issue_type/_emoji_block.html.haml4
-rw-r--r--app/views/shared/labels/_form.html.haml8
-rw-r--r--app/views/shared/members/_access_request_links.html.haml7
-rw-r--r--app/views/shared/milestones/_delete_button.html.haml8
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml2
-rw-r--r--app/views/shared/projects/_list.html.haml2
-rw-r--r--app/views/shared/projects/_project.html.haml146
-rw-r--r--app/views/shared/web_hooks/_form.html.haml20
-rw-r--r--app/views/snippets/show.html.haml2
-rw-r--r--app/views/users/show.html.haml43
-rw-r--r--app/workers/all_queues.yml49
-rw-r--r--app/workers/authorized_keys_worker.rb2
-rw-r--r--app/workers/bulk_imports/entity_worker.rb45
-rw-r--r--app/workers/bulk_imports/export_request_worker.rb79
-rw-r--r--app/workers/bulk_imports/pipeline_worker.rb79
-rw-r--r--app/workers/cluster_configure_istio_worker.rb9
-rw-r--r--app/workers/cluster_install_app_worker.rb9
-rw-r--r--app/workers/cluster_patch_app_worker.rb9
-rw-r--r--app/workers/cluster_update_app_worker.rb36
-rw-r--r--app/workers/cluster_upgrade_app_worker.rb9
-rw-r--r--app/workers/cluster_wait_for_app_installation_worker.rb9
-rw-r--r--app/workers/cluster_wait_for_app_update_worker.rb9
-rw-r--r--app/workers/cluster_wait_for_ingress_ip_address_worker.rb9
-rw-r--r--app/workers/clusters/applications/deactivate_integration_worker.rb2
-rw-r--r--app/workers/clusters/applications/wait_for_uninstall_app_worker.rb9
-rw-r--r--app/workers/concerns/limited_capacity/job_tracker.rb2
-rw-r--r--app/workers/container_registry/cleanup_worker.rb59
-rw-r--r--app/workers/container_registry/delete_container_repository_worker.rb81
-rw-r--r--app/workers/database/batched_background_migration/execution_worker.rb75
-rw-r--r--app/workers/database/batched_background_migration/single_database_worker.rb11
-rw-r--r--app/workers/gitlab/github_import/advance_stage_worker.rb1
-rw-r--r--app/workers/gitlab/github_import/pull_requests/import_review_request_worker.rb25
-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_review_requests_worker.rb33
-rw-r--r--app/workers/gitlab_performance_bar_stats_worker.rb6
-rw-r--r--app/workers/gitlab_shell_worker.rb12
-rw-r--r--app/workers/incident_management/add_severity_system_note_worker.rb5
-rw-r--r--app/workers/loose_foreign_keys/cleanup_worker.rb6
-rw-r--r--app/workers/mail_scheduler/notification_service_worker.rb6
-rw-r--r--app/workers/merge_requests/delete_branch_worker.rb27
-rw-r--r--app/workers/merge_requests/delete_source_branch_worker.rb13
-rw-r--r--app/workers/namespaces/root_statistics_worker.rb3
-rw-r--r--app/workers/onboarding/issue_created_worker.rb3
-rw-r--r--app/workers/onboarding/pipeline_created_worker.rb3
-rw-r--r--app/workers/onboarding/progress_worker.rb3
-rw-r--r--app/workers/onboarding/user_added_worker.rb3
-rw-r--r--app/workers/pages_worker.rb2
-rw-r--r--app/workers/projects/inactive_projects_deletion_cron_worker.rb10
-rw-r--r--app/workers/projects/post_creation_worker.rb15
-rw-r--r--app/workers/repository_import_worker.rb6
-rw-r--r--app/workers/run_pipeline_schedule_worker.rb13
-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/users/migrate_records_to_ghost_user_in_batches_worker.rb2
1344 files changed, 15900 insertions, 12048 deletions
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 ce5342ad1ea..d24285af5c3 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
@@ -104,18 +104,13 @@ export default {
@[$options.EVENT_ERROR]="onError"
@[$options.EVENT_SUCCESS]="onSuccess"
>
- <div ref="container">
+ <div ref="container" data-testid="access-token-section" data-qa-selector="access_token_section">
<template v-if="newToken">
- <!--
- After issue https://gitlab.com/gitlab-org/gitlab/-/issues/360921 is
- closed remove the `initial-visibility`.
- -->
<input-copy-toggle-visibility
:copy-button-title="copyButtonTitle"
:label="label"
:label-for="$options.tokenInputId"
:value="newToken"
- initial-visibility
:form-input-group-props="formInputGroupProps"
>
<template #description>
diff --git a/app/assets/javascripts/admin/application_settings/runner_token_expiration/index.js b/app/assets/javascripts/admin/application_settings/runner_token_expiration/index.js
index 79d7ff0451a..8c01a3bc607 100644
--- a/app/assets/javascripts/admin/application_settings/runner_token_expiration/index.js
+++ b/app/assets/javascripts/admin/application_settings/runner_token_expiration/index.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import { parseInterval } from '~/runner/utils';
+import { parseInterval } from '~/ci/runner/utils';
import ExpirationIntervals from './components/expiration_intervals.vue';
const initRunnerTokenExpirationIntervals = (selector = '#js-runner-token-expiration-intervals') => {
diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue
index ae0c6731271..d4f9ff4e529 100644
--- a/app/assets/javascripts/admin/users/components/actions/delete.vue
+++ b/app/assets/javascripts/admin/users/components/actions/delete.vue
@@ -12,6 +12,10 @@ export default {
type: String,
required: true,
},
+ userId: {
+ type: Number,
+ required: true,
+ },
paths: {
type: Object,
required: true,
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 a39df1cbfb6..413804c9a3b 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,17 +1,26 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { GlDropdownItem, 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';
export default {
+ i18n: {
+ loading: __('Loading'),
+ },
components: {
GlDropdownItem,
+ GlLoadingIcon,
},
props: {
username: {
type: String,
required: true,
},
+ userId: {
+ type: Number,
+ required: true,
+ },
paths: {
type: Object,
required: true,
@@ -22,21 +31,38 @@ export default {
default: () => [],
},
},
+ data() {
+ return {
+ loading: false,
+ };
+ },
methods: {
- onClick() {
+ async onClick() {
+ this.loading = true;
+ try {
+ const { data: associationsCountData } = await associationsCount(this.userId);
+ this.openModal(associationsCountData);
+ } catch (error) {
+ this.openModal(new Error());
+ } finally {
+ this.loading = false;
+ }
+ },
+ openModal(associationsCountData) {
const { username, paths, userDeletionObstacles } = this;
eventHub.$emit(EVENT_OPEN_DELETE_USER_MODAL, {
username,
blockPath: paths.block,
deletePath: paths.deleteWithContributions,
userDeletionObstacles,
+ associationsCount: associationsCountData,
i18n: {
title: s__('AdminUsers|Delete User %{username} and contributions?'),
primaryButtonLabel: s__('AdminUsers|Delete user and contributions'),
- messageBody: s__(`AdminUsers|You are about to permanently delete the user %{username}. This will delete all of the issues,
- merge requests, and groups linked to them. To avoid data loss,
- consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd},
- it cannot be undone or recovered.`),
+ messageBody: s__(`AdminUsers|You are about to permanently delete the user %{username}. This will delete all issues,
+ merge requests, groups, and projects linked to them. To avoid data loss,
+ consider using the %{strongStart}Block user%{strongEnd} feature instead. After you %{strongStart}Delete user%{strongEnd},
+ you cannot undo this action or recover the data.`),
},
});
},
@@ -45,8 +71,12 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <span class="gl-text-red-500">
+ <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>
diff --git a/app/assets/javascripts/admin/users/components/associations/associations_list.vue b/app/assets/javascripts/admin/users/components/associations/associations_list.vue
new file mode 100644
index 00000000000..12f98a02809
--- /dev/null
+++ b/app/assets/javascripts/admin/users/components/associations/associations_list.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import AssociationsListItem from './associations_list_item.vue';
+
+export default {
+ i18n: {
+ errorMessage: s__(
+ "AdminUsers|An error occurred while fetching this user's contributions, and the request cannot return the number of issues, merge requests, groups, and projects linked to this user. If you proceed with deleting the user, all their contributions will still be deleted.",
+ ),
+ },
+ components: {
+ AssociationsListItem,
+ GlAlert,
+ },
+ props: {
+ associationsCount: {
+ type: [Object, Error],
+ required: true,
+ },
+ },
+ computed: {
+ hasError() {
+ return this.associationsCount instanceof Error;
+ },
+ hasAssociations() {
+ return Object.values(this.associationsCount).some((count) => count > 0);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-alert v-if="hasError" class="gl-mb-5" variant="danger" :dismissible="false">{{
+ $options.i18n.errorMessage
+ }}</gl-alert>
+ <ul v-else-if="hasAssociations" class="gl-mb-5">
+ <associations-list-item
+ v-if="associationsCount.groups_count"
+ :message="n__('%{count} group', '%{count} groups', associationsCount.groups_count)"
+ :count="associationsCount.groups_count"
+ />
+ <associations-list-item
+ v-if="associationsCount.projects_count"
+ :message="n__('%{count} project', '%{count} projects', associationsCount.projects_count)"
+ :count="associationsCount.projects_count"
+ />
+ <associations-list-item
+ v-if="associationsCount.issues_count"
+ :message="n__('%{count} issue', '%{count} issues', associationsCount.issues_count)"
+ :count="associationsCount.issues_count"
+ />
+ <associations-list-item
+ v-if="associationsCount.merge_requests_count"
+ :message="
+ n__(
+ '%{count} merge request',
+ '%{count} merge requests',
+ associationsCount.merge_requests_count,
+ )
+ "
+ :count="associationsCount.merge_requests_count"
+ />
+ </ul>
+</template>
diff --git a/app/assets/javascripts/admin/users/components/associations/associations_list_item.vue b/app/assets/javascripts/admin/users/components/associations/associations_list_item.vue
new file mode 100644
index 00000000000..88cb24aaf8f
--- /dev/null
+++ b/app/assets/javascripts/admin/users/components/associations/associations_list_item.vue
@@ -0,0 +1,27 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+
+export default {
+ components: { GlSprintf },
+ props: {
+ message: {
+ type: String,
+ required: true,
+ },
+ count: {
+ type: Number,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <li>
+ <gl-sprintf :message="message">
+ <template #count>
+ <strong>{{ count }}</strong>
+ </template>
+ </gl-sprintf>
+ </li>
+</template>
diff --git a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue
index 31fe86775ee..7f02d6dd5b1 100644
--- a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue
+++ b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue
@@ -2,6 +2,7 @@
import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
+import AssociationsList from '../associations/associations_list.vue';
import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from './delete_user_modal_event_hub';
export default {
@@ -11,6 +12,7 @@ export default {
GlFormInput,
GlSprintf,
UserDeletionObstaclesList,
+ AssociationsList,
},
props: {
csrfToken: {
@@ -25,6 +27,7 @@ export default {
blockPath: '',
deletePath: '',
userDeletionObstacles: [],
+ associationsCount: {},
i18n: {
title: '',
primaryButtonLabel: '',
@@ -53,11 +56,19 @@ export default {
eventHub.$off(EVENT_OPEN_DELETE_USER_MODAL, this.onOpenEvent);
},
methods: {
- onOpenEvent({ username, blockPath, deletePath, userDeletionObstacles, i18n }) {
+ onOpenEvent({
+ username,
+ blockPath,
+ deletePath,
+ userDeletionObstacles,
+ associationsCount = {},
+ i18n,
+ }) {
this.username = username;
this.blockPath = blockPath;
this.deletePath = deletePath;
this.userDeletionObstacles = userDeletionObstacles;
+ this.associationsCount = associationsCount;
this.i18n = i18n;
this.openModal();
},
@@ -100,8 +111,10 @@ export default {
:user-name="trimmedUsername"
/>
+ <associations-list :associations-count="associationsCount" />
+
<p>
- <gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')">
+ <gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}.')">
<template #username>
<code data-testid="confirm-username" class="gl-white-space-pre-wrap">{{
trimmedUsername
diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue
index 691a292673c..c1fb80959cf 100644
--- a/app/assets/javascripts/admin/users/components/user_actions.vue
+++ b/app/assets/javascripts/admin/users/components/user_actions.vue
@@ -139,6 +139,7 @@ export default {
:key="action"
:paths="userPaths"
:username="user.name"
+ :user-id="user.id"
:user-deletion-obstacles="obstaclesForUserDeletion"
:data-testid="`delete-${action}`"
>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
index 6b5aac57f1c..03bc4b825ae 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
@@ -612,7 +612,7 @@ export default {
>
<alert-settings-form-help-block
:message="viewCredentialsHelpMsg"
- link="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html"
+ link="https://docs.gitlab.com/ee/operations/incident_management/integrations.html#configuration"
/>
<gl-form-group id="integration-webhook">
diff --git a/app/assets/javascripts/analytics/shared/components/daterange.vue b/app/assets/javascripts/analytics/shared/components/daterange.vue
index 92ccac59057..a14b0bafecf 100644
--- a/app/assets/javascripts/analytics/shared/components/daterange.vue
+++ b/app/assets/javascripts/analytics/shared/components/daterange.vue
@@ -49,7 +49,7 @@ export default {
return {
maxDateRangeTooltip: sprintf(
__(
- 'Showing data for workflow items created in this date range. Date range limited to %{maxDateRange} days.',
+ 'Showing data for workflow items completed in this date range. Date range limited to %{maxDateRange} days.',
),
{
maxDateRange: this.maxDateRange,
@@ -89,6 +89,8 @@ export default {
:default-max-date="maxDate"
:same-day-selection="includeSelectedDate"
:tooltip="maxDateRangeTooltip"
+ :from-label="__('From')"
+ :to-label="__('To')"
theme="animate-picker"
start-picker-class="js-daterange-picker-from gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-lg-align-items-center gl-lg-mr-3 gl-mb-2 gl-lg-mb-0"
end-picker-class="js-daterange-picker-to gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-lg-align-items-center gl-mb-2 gl-lg-mb-0"
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 b2e554bc913..5bb60d91f1e 100644
--- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
+++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
@@ -240,7 +240,7 @@ export default {
</template>
<template #header>
<gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header>
- <gl-search-box-by-type v-model.trim="searchTerm" />
+ <gl-search-box-by-type v-model.trim="searchTerm" :placeholder="__('Search')" />
</template>
<template #highlighted-items>
<gl-dropdown-item
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 ffb61230661..cc7b554f32c 100644
--- a/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue
+++ b/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue
@@ -1,33 +1,11 @@
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
-import { flatten, isEqual, keyBy } from 'lodash';
+import { isEqual, keyBy } from 'lodash';
import { createAlert } from '~/flash';
import { sprintf, s__ } from '~/locale';
-import { METRICS_POPOVER_CONTENT } from '../constants';
-import { removeFlash, prepareTimeMetricsData } from '../utils';
+import { fetchMetricsData, removeFlash } from '../utils';
import MetricTile from './metric_tile.vue';
-const requestData = ({ request, endpoint, path, params, name }) => {
- return request({ endpoint, params, requestPath: path })
- .then(({ data }) => data)
- .catch(() => {
- const message = sprintf(
- s__(
- 'ValueStreamAnalytics|There was an error while fetching value stream analytics %{requestTypeName} data.',
- ),
- { requestTypeName: name },
- );
- createAlert({ message });
- });
-};
-
-const fetchMetricsData = (reqs = [], path, params) => {
- const promises = reqs.map((r) => requestData({ ...r, path, params }));
- return Promise.all(promises).then((responses) =>
- prepareTimeMetricsData(flatten(responses), METRICS_POPOVER_CONTENT),
- );
-};
-
const extractMetricsGroupData = (keyList = [], data = []) => {
if (!keyList.length || !data.length) return [];
const kv = keyBy(data, 'identifier');
@@ -111,7 +89,15 @@ export default {
this.isLoading = false;
})
- .catch(() => {
+ .catch((err) => {
+ const message = sprintf(
+ s__(
+ 'ValueStreamAnalytics|There was an error while fetching value stream analytics %{requestTypeName} data.',
+ ),
+ { requestTypeName: err.message },
+ );
+
+ createAlert({ message });
this.isLoading = false;
});
},
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
index bc52e38fc81..71b719d1ed2 100644
--- a/app/assets/javascripts/analytics/shared/utils.js
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -1,8 +1,9 @@
+import { flatten } from 'lodash';
import { hideFlash } from '~/flash';
import dateFormat from '~/lib/dateformat';
import { slugify } from '~/lib/utils/text_utility';
import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
-import { dateFormats } from './constants';
+import { dateFormats, METRICS_POPOVER_CONTENT } from './constants';
export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'name') => {
if (!searchTerm?.length) return data;
@@ -96,3 +97,28 @@ export const prepareTimeMetricsData = (data = [], popoverContent = {}) =>
description: popoverContent[metricIdentifier]?.description || '',
};
});
+
+const requestData = ({ request, endpoint, requestPath, params, name }) => {
+ return request({ endpoint, params, requestPath })
+ .then(({ data }) => data)
+ .catch(() => {
+ throw new Error(name);
+ });
+};
+
+/**
+ * Takes a configuration array of metrics requests (key metrics and DORA) and returns
+ * a flat array of all the responses. Different metrics are retrieved from different endpoints
+ * additionally we only support certain metrics for FOSS users.
+ *
+ * @param {Array} requests - array of metric api requests to be made
+ * @param {String} requestPath - path for the group / project we are requesting
+ * @param {Object} params - optional parameters to filter, including `created_after` and `created_before` dates
+ * @returns a flat array of metrics
+ */
+export const fetchMetricsData = (requests = [], requestPath, params) => {
+ const promises = requests.map((r) => requestData({ ...r, requestPath, params }));
+ return Promise.all(promises).then((responses) =>
+ prepareTimeMetricsData(flatten(responses), METRICS_POPOVER_CONTENT),
+ );
+};
diff --git a/app/assets/javascripts/api/groups_api.js b/app/assets/javascripts/api/groups_api.js
index 48cf346d0e6..e859160c2e7 100644
--- a/app/assets/javascripts/api/groups_api.js
+++ b/app/assets/javascripts/api/groups_api.js
@@ -5,6 +5,7 @@ import { buildApiUrl } from './api_utils';
const GROUP_PATH = '/api/:version/groups/:id';
const GROUPS_PATH = '/api/:version/groups.json';
const DESCENDANT_GROUPS_PATH = '/api/:version/groups/:id/descendant_groups';
+const GROUP_TRANSFER_LOCATIONS_PATH = 'api/:version/groups/:id/transfer_locations';
const axiosGet = (url, query, options, callback) => {
return axios
@@ -37,3 +38,10 @@ export function updateGroup(groupId, data = {}) {
return axios.put(url, data);
}
+
+export const getGroupTransferLocations = (groupId, params = {}) => {
+ const url = buildApiUrl(GROUP_TRANSFER_LOCATIONS_PATH).replace(':id', groupId);
+ const defaultParams = { per_page: DEFAULT_PER_PAGE };
+
+ return axios.get(url, { params: { ...defaultParams, ...params } });
+};
diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js
index 369abe95d49..0f874e35684 100644
--- a/app/assets/javascripts/api/user_api.js
+++ b/app/assets/javascripts/api/user_api.js
@@ -12,6 +12,7 @@ const USER_PROJECTS_PATH = '/api/:version/users/:id/projects';
const USER_POST_STATUS_PATH = '/api/:version/user/status';
const USER_FOLLOW_PATH = '/api/:version/users/:id/follow';
const USER_UNFOLLOW_PATH = '/api/:version/users/:id/unfollow';
+const USER_ASSOCIATIONS_COUNT_PATH = '/api/:version/users/:id/associations_count';
export function getUsers(query, options) {
const url = buildApiUrl(USERS_PATH);
@@ -81,3 +82,8 @@ export function unfollowUser(userId) {
const url = buildApiUrl(USER_UNFOLLOW_PATH).replace(':id', encodeURIComponent(userId));
return axios.post(url);
}
+
+export function associationsCount(userId) {
+ const url = buildApiUrl(USER_ASSOCIATIONS_COUNT_PATH).replace(':id', encodeURIComponent(userId));
+ return axios.get(url);
+}
diff --git a/app/assets/javascripts/artifacts/components/artifact_delete_modal.vue b/app/assets/javascripts/artifacts/components/artifact_delete_modal.vue
new file mode 100644
index 00000000000..14edd73824e
--- /dev/null
+++ b/app/assets/javascripts/artifacts/components/artifact_delete_modal.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+
+import {
+ I18N_MODAL_TITLE,
+ I18N_MODAL_BODY,
+ I18N_MODAL_PRIMARY,
+ I18N_MODAL_CANCEL,
+} from '../constants';
+
+export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ artifactName: {
+ type: String,
+ required: true,
+ },
+ deleteInProgress: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ actionPrimary() {
+ return {
+ text: I18N_MODAL_PRIMARY,
+ attributes: { variant: 'danger', loading: this.deleteInProgress },
+ };
+ },
+ },
+ actionCancel: { text: I18N_MODAL_CANCEL },
+ i18n: {
+ title: I18N_MODAL_TITLE,
+ body: I18N_MODAL_BODY,
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="modal"
+ modal-id="artifact-delete-modal"
+ size="sm"
+ :title="$options.i18n.title(artifactName)"
+ :action-primary="actionPrimary"
+ :action-cancel="$options.actionCancel"
+ v-bind="$attrs"
+ v-on="$listeners"
+ >
+ {{ $options.i18n.body }}
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/artifacts/components/artifact_row.vue b/app/assets/javascripts/artifacts/components/artifact_row.vue
new file mode 100644
index 00000000000..8c03db2acd1
--- /dev/null
+++ b/app/assets/javascripts/artifacts/components/artifact_row.vue
@@ -0,0 +1,87 @@
+<script>
+import { GlButtonGroup, GlButton, GlBadge, GlFriendlyWrap } from '@gitlab/ui';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { I18N_EXPIRED, I18N_DOWNLOAD, I18N_DELETE } from '../constants';
+
+export default {
+ name: 'ArtifactRow',
+ components: {
+ GlButtonGroup,
+ GlButton,
+ GlBadge,
+ GlFriendlyWrap,
+ },
+ props: {
+ artifact: {
+ type: Object,
+ required: true,
+ },
+ isLastRow: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ isExpired() {
+ if (!this.artifact.expireAt) {
+ return false;
+ }
+ return Date.now() > new Date(this.artifact.expireAt).getTime();
+ },
+ artifactSize() {
+ return numberToHumanSize(this.artifact.size);
+ },
+ },
+ i18n: {
+ expired: I18N_EXPIRED,
+ download: I18N_DOWNLOAD,
+ delete: I18N_DELETE,
+ },
+};
+</script>
+<template>
+ <div
+ class="gl-py-4"
+ :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
+ class="gl-w-half gl-pl-8 gl-display-flex gl-align-items-center"
+ data-testid="job-artifact-row-name"
+ >
+ <gl-friendly-wrap :text="artifact.name" />
+ <gl-badge size="sm" variant="neutral" class="gl-ml-2">
+ {{ artifact.fileType.toLowerCase() }}
+ </gl-badge>
+ <gl-badge v-if="isExpired" size="sm" variant="warning" icon="expire" class="gl-ml-2">
+ {{ $options.i18n.expired }}
+ </gl-badge>
+ </span>
+
+ <span class="gl-w-quarter gl-text-right gl-pr-5" data-testid="job-artifact-row-size">
+ {{ artifactSize }}
+ </span>
+
+ <span class="gl-w-quarter gl-text-right gl-pr-5">
+ <gl-button-group>
+ <gl-button
+ category="tertiary"
+ icon="download"
+ :title="$options.i18n.download"
+ :aria-label="$options.i18n.download"
+ :href="artifact.downloadPath"
+ data-testid="job-artifact-row-download-button"
+ />
+ <gl-button
+ category="tertiary"
+ icon="remove"
+ :title="$options.i18n.delete"
+ :aria-label="$options.i18n.delete"
+ data-testid="job-artifact-row-delete-button"
+ @click="$emit('delete')"
+ />
+ </gl-button-group>
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue b/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue
new file mode 100644
index 00000000000..4a826d0d462
--- /dev/null
+++ b/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue
@@ -0,0 +1,118 @@
+<script>
+import { createAlert } from '~/flash';
+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';
+import { removeArtifactFromStore } from '../graphql/cache_update';
+import {
+ I18N_DESTROY_ERROR,
+ ARTIFACT_ROW_HEIGHT,
+ ARTIFACTS_SHOWN_WITHOUT_SCROLLING,
+} from '../constants';
+import ArtifactRow from './artifact_row.vue';
+import ArtifactDeleteModal from './artifact_delete_modal.vue';
+
+export default {
+ name: 'ArtifactsTableRowDetails',
+ components: {
+ DynamicScroller,
+ DynamicScrollerItem,
+ ArtifactRow,
+ ArtifactDeleteModal,
+ },
+ props: {
+ artifacts: {
+ type: Object,
+ required: true,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isModalVisible: false,
+ deleteInProgress: false,
+ deletingArtifactId: null,
+ deletingArtifactName: '',
+ };
+ },
+ computed: {
+ scrollContainerStyle() {
+ /*
+ limit the height of the expanded artifacts container to a number of artifacts
+ if a job has more artifacts than ARTIFACTS_SHOWN_WITHOUT_SCROLLING, scroll to see the rest
+ add one pixel to row height to account for borders
+ */
+ return { maxHeight: `${ARTIFACTS_SHOWN_WITHOUT_SCROLLING * (ARTIFACT_ROW_HEIGHT + 1)}px` };
+ },
+ },
+ methods: {
+ isLastRow(index) {
+ return index === this.artifacts.nodes.length - 1;
+ },
+ showModal(item) {
+ this.deletingArtifactId = item.id;
+ this.deletingArtifactName = item.name;
+ this.isModalVisible = true;
+ },
+ hideModal() {
+ this.isModalVisible = false;
+ },
+ clearModal() {
+ this.deletingArtifactId = null;
+ this.deletingArtifactName = '';
+ },
+ destroyArtifact() {
+ const id = this.deletingArtifactId;
+ this.deleteInProgress = true;
+
+ this.$apollo
+ .mutate({
+ mutation: destroyArtifactMutation,
+ variables: { id },
+ update: (store) => {
+ removeArtifactFromStore(store, id, getJobArtifactsQuery, this.queryVariables);
+ },
+ })
+ .catch(() => {
+ createAlert({
+ message: I18N_DESTROY_ERROR,
+ });
+ this.$emit('refetch');
+ })
+ .finally(() => {
+ this.deleteInProgress = false;
+ this.clearModal();
+ });
+ },
+ },
+ ARTIFACT_ROW_HEIGHT,
+};
+</script>
+<template>
+ <div :style="scrollContainerStyle">
+ <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-last-row="isLastRow(index)"
+ @delete="showModal(item)"
+ />
+ </dynamic-scroller-item>
+ </template>
+ </dynamic-scroller>
+ <artifact-delete-modal
+ :artifact-name="deletingArtifactName"
+ :visible="isModalVisible"
+ :delete-in-progress="deleteInProgress"
+ @primary="destroyArtifact"
+ @cancel="hideModal"
+ @close="hideModal"
+ @hide="hideModal"
+ @hidden="clearModal"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/artifacts/components/job_artifacts_table.vue
new file mode 100644
index 00000000000..34e443f4e58
--- /dev/null
+++ b/app/assets/javascripts/artifacts/components/job_artifacts_table.vue
@@ -0,0 +1,337 @@
+<script>
+import {
+ GlLoadingIcon,
+ GlTable,
+ GlLink,
+ GlButtonGroup,
+ GlButton,
+ GlBadge,
+ GlIcon,
+ GlPagination,
+} from '@gitlab/ui';
+import { createAlert } from '~/flash';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql';
+import { totalArtifactsSizeForJob, mapArchivesToJobNodes, mapBooleansToJobNodes } from '../utils';
+import {
+ STATUS_BADGE_VARIANTS,
+ I18N_DOWNLOAD,
+ I18N_BROWSE,
+ I18N_DELETE,
+ I18N_EXPIRED,
+ I18N_DESTROY_ERROR,
+ I18N_FETCH_ERROR,
+ I18N_ARTIFACTS,
+ I18N_JOB,
+ I18N_SIZE,
+ I18N_CREATED,
+ I18N_ARTIFACTS_COUNT,
+ INITIAL_CURRENT_PAGE,
+ INITIAL_PREVIOUS_PAGE_CURSOR,
+ INITIAL_NEXT_PAGE_CURSOR,
+ JOBS_PER_PAGE,
+ INITIAL_LAST_PAGE_SIZE,
+} from '../constants';
+import ArtifactsTableRowDetails from './artifacts_table_row_details.vue';
+
+const INITIAL_PAGINATION_STATE = {
+ currentPage: INITIAL_CURRENT_PAGE,
+ prevPageCursor: INITIAL_PREVIOUS_PAGE_CURSOR,
+ nextPageCursor: INITIAL_NEXT_PAGE_CURSOR,
+ firstPageSize: JOBS_PER_PAGE,
+ lastPageSize: INITIAL_LAST_PAGE_SIZE,
+};
+
+export default {
+ name: 'JobArtifactsTable',
+ components: {
+ GlLoadingIcon,
+ GlTable,
+ GlLink,
+ GlButtonGroup,
+ GlButton,
+ GlBadge,
+ GlIcon,
+ GlPagination,
+ CiIcon,
+ TimeAgo,
+ ArtifactsTableRowDetails,
+ },
+ inject: ['projectPath'],
+ apollo: {
+ jobArtifacts: {
+ query: getJobArtifactsQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ update({ project: { jobs: { nodes = [], pageInfo = {}, count = 0 } = {} } }) {
+ this.pageInfo = pageInfo;
+ this.count = count;
+ return nodes
+ .map(mapArchivesToJobNodes)
+ .map(mapBooleansToJobNodes)
+ .map((jobNode) => {
+ return {
+ ...jobNode,
+ // GlTable uses an item's _showDetails attribute to determine whether
+ // it should show the <template #row-details /> for its table row
+ _showDetails: this.expandedJobs.includes(jobNode.id),
+ };
+ });
+ },
+ error() {
+ createAlert({
+ message: I18N_FETCH_ERROR,
+ });
+ },
+ },
+ },
+ data() {
+ return {
+ jobArtifacts: [],
+ count: 0,
+ pageInfo: {},
+ expandedJobs: [],
+ pagination: INITIAL_PAGINATION_STATE,
+ };
+ },
+ computed: {
+ queryVariables() {
+ return {
+ projectPath: this.projectPath,
+ firstPageSize: this.pagination.firstPageSize,
+ lastPageSize: this.pagination.lastPageSize,
+ prevPageCursor: this.pagination.prevPageCursor,
+ nextPageCursor: this.pagination.nextPageCursor,
+ };
+ },
+ showPagination() {
+ return this.count > JOBS_PER_PAGE;
+ },
+ prevPage() {
+ return Number(this.pageInfo.hasPreviousPage);
+ },
+ nextPage() {
+ return Number(this.pageInfo.hasNextPage);
+ },
+ },
+ methods: {
+ refetchArtifacts() {
+ this.$apollo.queries.jobArtifacts.refetch();
+ },
+ artifactsSize(item) {
+ return totalArtifactsSizeForJob(item);
+ },
+ pipelineId(item) {
+ const id = getIdFromGraphQLId(item.pipeline.id);
+ return `#${id}`;
+ },
+ handlePageChange(page) {
+ const { startCursor, endCursor } = this.pageInfo;
+
+ if (page > this.pagination.currentPage) {
+ this.pagination = {
+ ...INITIAL_PAGINATION_STATE,
+ nextPageCursor: endCursor,
+ currentPage: page,
+ };
+ } else {
+ this.pagination = {
+ lastPageSize: JOBS_PER_PAGE,
+ firstPageSize: null,
+ prevPageCursor: startCursor,
+ currentPage: page,
+ };
+ }
+ },
+ handleRowToggle(toggleDetails, hasArtifacts, id, detailsShowing) {
+ if (!hasArtifacts) return;
+ toggleDetails();
+
+ if (!detailsShowing) {
+ this.expandedJobs.push(id);
+ } else {
+ this.expandedJobs.splice(this.expandedJobs.indexOf(id), 1);
+ }
+ },
+ downloadPath(job) {
+ return job.archive?.downloadPath;
+ },
+ downloadButtonDisabled(job) {
+ return !job.archive?.downloadPath;
+ },
+ browseButtonDisabled(job) {
+ return !job.browseArtifactsPath;
+ },
+ },
+ fields: [
+ {
+ key: 'artifacts',
+ label: I18N_ARTIFACTS,
+ thClass: 'gl-w-quarter',
+ },
+ {
+ key: 'job',
+ label: I18N_JOB,
+ thClass: 'gl-w-35p',
+ },
+ {
+ key: 'size',
+ label: I18N_SIZE,
+ thClass: 'gl-w-15p gl-text-right',
+ tdClass: 'gl-text-right',
+ },
+ {
+ key: 'created',
+ label: I18N_CREATED,
+ thClass: 'gl-w-eighth gl-text-center',
+ tdClass: 'gl-text-center',
+ },
+ {
+ key: 'actions',
+ label: '',
+ thClass: 'gl-w-eighth',
+ tdClass: 'gl-text-right',
+ },
+ ],
+ STATUS_BADGE_VARIANTS,
+ i18n: {
+ download: I18N_DOWNLOAD,
+ browse: I18N_BROWSE,
+ delete: I18N_DELETE,
+ expired: I18N_EXPIRED,
+ destroyArtifactError: I18N_DESTROY_ERROR,
+ fetchArtifactsError: I18N_FETCH_ERROR,
+ artifactsLabel: I18N_ARTIFACTS,
+ jobLabel: I18N_JOB,
+ sizeLabel: I18N_SIZE,
+ createdLabel: I18N_CREATED,
+ artifactsCount: I18N_ARTIFACTS_COUNT,
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-table
+ :items="jobArtifacts"
+ :fields="$options.fields"
+ :busy="$apollo.queries.jobArtifacts.loading"
+ stacked="sm"
+ details-td-class="gl-bg-gray-10! gl-p-0! gl-overflow-auto"
+ >
+ <template #table-busy>
+ <gl-loading-icon size="lg" />
+ </template>
+ <template
+ #cell(artifacts)="{ item: { id, artifacts, hasArtifacts }, toggleDetails, detailsShowing }"
+ >
+ <span
+ :class="{ 'gl-cursor-pointer': hasArtifacts }"
+ data-testid="job-artifacts-count"
+ @click="handleRowToggle(toggleDetails, hasArtifacts, id, detailsShowing)"
+ >
+ <gl-icon
+ v-if="hasArtifacts"
+ :name="detailsShowing ? 'chevron-down' : 'chevron-right'"
+ class="gl-mr-2"
+ />
+ <strong>
+ {{ $options.i18n.artifactsCount(artifacts.nodes.length) }}
+ </strong>
+ </span>
+ </template>
+ <template #cell(job)="{ item }">
+ <span class="gl-display-inline-flex gl-align-items-center gl-w-full gl-mb-4">
+ <span data-testid="job-artifacts-job-status">
+ <ci-icon v-if="item.succeeded" :status="item.detailedStatus" class="gl-mr-3" />
+ <gl-badge
+ v-else
+ :icon="item.detailedStatus.icon"
+ :variant="$options.STATUS_BADGE_VARIANTS[item.detailedStatus.group]"
+ class="gl-mr-3"
+ >
+ {{ item.detailedStatus.label }}
+ </gl-badge>
+ </span>
+ <gl-link :href="item.webPath" class="gl-font-weight-bold">
+ {{ item.name }}
+ </gl-link>
+ </span>
+ <span class="gl-display-inline-flex">
+ <gl-icon name="pipeline" class="gl-mr-2" />
+ <gl-link
+ :href="item.pipeline.path"
+ class="gl-text-black-normal gl-text-decoration-underline gl-mr-4"
+ >
+ {{ pipelineId(item) }}
+ </gl-link>
+ <gl-icon name="branch" class="gl-mr-2" />
+ <gl-link
+ :href="item.refPath"
+ class="gl-text-black-normal gl-text-decoration-underline gl-mr-4"
+ >
+ {{ item.refName }}
+ </gl-link>
+ <gl-icon name="commit" class="gl-mr-2" />
+ <gl-link
+ :href="item.commitPath"
+ class="gl-text-black-normal gl-text-decoration-underline gl-mr-4"
+ >
+ {{ item.shortSha }}
+ </gl-link>
+ </span>
+ </template>
+ <template #cell(size)="{ item }">
+ <span data-testid="job-artifacts-size">{{ artifactsSize(item) }}</span>
+ </template>
+ <template #cell(created)="{ item }">
+ <time-ago data-testid="job-artifacts-created" :time="item.finishedAt" />
+ </template>
+ <template #cell(actions)="{ item }">
+ <gl-button-group>
+ <gl-button
+ icon="download"
+ :disabled="downloadButtonDisabled(item)"
+ :href="downloadPath(item)"
+ :title="$options.i18n.download"
+ :aria-label="$options.i18n.download"
+ data-testid="job-artifacts-download-button"
+ />
+ <gl-button
+ icon="folder-open"
+ :disabled="browseButtonDisabled(item)"
+ :href="item.browseArtifactsPath"
+ :title="$options.i18n.browse"
+ :aria-label="$options.i18n.browse"
+ data-testid="job-artifacts-browse-button"
+ />
+ <gl-button
+ icon="remove"
+ :title="$options.i18n.delete"
+ :aria-label="$options.i18n.delete"
+ data-testid="job-artifacts-delete-button"
+ disabled
+ />
+ </gl-button-group>
+ </template>
+ <template #row-details="{ item: { artifacts } }">
+ <artifacts-table-row-details
+ :artifacts="artifacts"
+ :query-variables="queryVariables"
+ @refetch="refetchArtifacts"
+ />
+ </template>
+ </gl-table>
+ <gl-pagination
+ v-if="showPagination"
+ :value="pagination.currentPage"
+ :prev-page="prevPage"
+ :next-page="nextPage"
+ align="center"
+ class="gl-mt-3"
+ @input="handlePageChange"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/artifacts/constants.js b/app/assets/javascripts/artifacts/constants.js
new file mode 100644
index 00000000000..5fcc4f2b76e
--- /dev/null
+++ b/app/assets/javascripts/artifacts/constants.js
@@ -0,0 +1,55 @@
+import { __, s__, n__, sprintf } from '~/locale';
+
+export const JOB_STATUS_GROUP_SUCCESS = 'success';
+
+export const STATUS_BADGE_VARIANTS = {
+ success: 'success',
+ passed: 'success',
+ error: 'danger',
+ failed: 'danger',
+ pending: 'warning',
+ 'waiting-for-resource': 'warning',
+ 'failed-with-warnings': 'warning',
+ 'success-with-warnings': 'warning',
+ running: 'info',
+ canceled: 'neutral',
+ disabled: 'neutral',
+ scheduled: 'neutral',
+ manual: 'neutral',
+ notification: 'muted',
+ preparing: 'muted',
+ created: 'muted',
+ skipped: 'muted',
+ notfound: 'muted',
+};
+
+export const I18N_DOWNLOAD = __('Download');
+export const I18N_BROWSE = s__('Artifacts|Browse');
+export const I18N_DELETE = __('Delete');
+export const I18N_EXPIRED = __('Expired');
+export const I18N_DESTROY_ERROR = s__('Artifacts|An error occurred while deleting the artifact');
+export const I18N_FETCH_ERROR = s__('Artifacts|An error occurred while retrieving job artifacts');
+export const I18N_ARTIFACTS = __('Artifacts');
+export const I18N_JOB = __('Job');
+export const I18N_SIZE = __('Size');
+export const I18N_CREATED = __('Created');
+export const I18N_ARTIFACTS_COUNT = (count) => n__('%d file', '%d files', count);
+
+export const I18N_MODAL_TITLE = (artifactName) =>
+ sprintf(s__('Artifacts|Delete %{name}?'), { name: artifactName });
+export const I18N_MODAL_BODY = s__(
+ 'Artifacts|This artifact will be permanently deleted. Any reports generated from this artifact will be empty.',
+);
+export const I18N_MODAL_PRIMARY = s__('Artifacts|Delete artifact');
+export const I18N_MODAL_CANCEL = __('Cancel');
+
+export const INITIAL_CURRENT_PAGE = 1;
+export const INITIAL_PREVIOUS_PAGE_CURSOR = '';
+export const INITIAL_NEXT_PAGE_CURSOR = '';
+export const JOBS_PER_PAGE = 20;
+export const INITIAL_LAST_PAGE_SIZE = null;
+
+export const ARCHIVE_FILE_TYPE = 'ARCHIVE';
+
+export const ARTIFACT_ROW_HEIGHT = 56;
+export const ARTIFACTS_SHOWN_WITHOUT_SCROLLING = 4;
diff --git a/app/assets/javascripts/artifacts/graphql/cache_update.js b/app/assets/javascripts/artifacts/graphql/cache_update.js
new file mode 100644
index 00000000000..9fa6114c7d4
--- /dev/null
+++ b/app/assets/javascripts/artifacts/graphql/cache_update.js
@@ -0,0 +1,30 @@
+import produce from 'immer';
+
+export const hasErrors = ({ errors = [] }) => errors?.length;
+
+export function removeArtifactFromStore(store, deletedArtifactId, query, variables) {
+ if (hasErrors(deletedArtifactId)) return;
+
+ const sourceData = store.readQuery({
+ query,
+ variables,
+ });
+
+ const data = produce(sourceData, (draftData) => {
+ draftData.project.jobs.nodes = draftData.project.jobs.nodes.map((jobNode) => {
+ return {
+ ...jobNode,
+ artifacts: {
+ ...jobNode.artifacts,
+ nodes: jobNode.artifacts.nodes.filter(({ id }) => id !== deletedArtifactId),
+ },
+ };
+ });
+ });
+
+ store.writeQuery({
+ query,
+ variables,
+ data,
+ });
+}
diff --git a/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql b/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql
new file mode 100644
index 00000000000..529224b47e6
--- /dev/null
+++ b/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql
@@ -0,0 +1,7 @@
+mutation destroyArtifact($id: CiJobArtifactID!) {
+ artifactDestroy(input: { id: $id }) {
+ artifact {
+ id
+ }
+ }
+}
diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql
new file mode 100644
index 00000000000..89a24d7891e
--- /dev/null
+++ b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql
@@ -0,0 +1,57 @@
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+
+query getJobArtifacts(
+ $projectPath: ID!
+ $firstPageSize: Int
+ $lastPageSize: Int
+ $prevPageCursor: String = ""
+ $nextPageCursor: String = ""
+) {
+ project(fullPath: $projectPath) {
+ id
+ jobs(
+ statuses: [SUCCESS, FAILED]
+ first: $firstPageSize
+ last: $lastPageSize
+ after: $nextPageCursor
+ before: $prevPageCursor
+ ) {
+ count
+ nodes {
+ id
+ name
+ webPath
+ detailedStatus {
+ id
+ group
+ icon
+ label
+ }
+ pipeline {
+ id
+ iid
+ path
+ }
+ refName
+ refPath
+ shortSha
+ commitPath
+ finishedAt
+ browseArtifactsPath
+ artifacts {
+ nodes {
+ id
+ name
+ fileType
+ downloadPath
+ size
+ expireAt
+ }
+ }
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/artifacts/index.js b/app/assets/javascripts/artifacts/index.js
new file mode 100644
index 00000000000..b5146e0f0e9
--- /dev/null
+++ b/app/assets/javascripts/artifacts/index.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import JobArtifactsTable from './components/job_artifacts_table.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export const initArtifactsTable = () => {
+ const el = document.querySelector('#js-artifact-management');
+
+ if (!el) {
+ return false;
+ }
+
+ const { projectPath } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ projectPath,
+ },
+ render: (createElement) => createElement(JobArtifactsTable),
+ });
+};
diff --git a/app/assets/javascripts/artifacts/utils.js b/app/assets/javascripts/artifacts/utils.js
new file mode 100644
index 00000000000..ebcf0af8d2a
--- /dev/null
+++ b/app/assets/javascripts/artifacts/utils.js
@@ -0,0 +1,26 @@
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { ARCHIVE_FILE_TYPE, JOB_STATUS_GROUP_SUCCESS } from './constants';
+
+export const totalArtifactsSizeForJob = (job) =>
+ numberToHumanSize(
+ job.artifacts.nodes
+ .map((artifact) => artifact.size)
+ .reduce((total, artifact) => total + artifact, 0),
+ );
+
+export const mapArchivesToJobNodes = (jobNode) => {
+ return {
+ archive: {
+ ...jobNode.artifacts.nodes.find((artifact) => artifact.fileType === ARCHIVE_FILE_TYPE),
+ },
+ ...jobNode,
+ };
+};
+
+export const mapBooleansToJobNodes = (jobNode) => {
+ return {
+ succeeded: jobNode.detailedStatus.group === JOB_STATUS_GROUP_SUCCESS,
+ hasArtifacts: jobNode.artifacts.nodes.length > 0,
+ ...jobNode,
+ };
+};
diff --git a/app/assets/javascripts/behaviors/copy_code.js b/app/assets/javascripts/behaviors/copy_code.js
index a653769b60f..ae186aba32d 100644
--- a/app/assets/javascripts/behaviors/copy_code.js
+++ b/app/assets/javascripts/behaviors/copy_code.js
@@ -53,9 +53,10 @@ export const initCopyCodeButton = (selector = '#content-body') => {
customElements.define('copy-code', CopyCodeButton);
}
+ const exclude = document.querySelector('.file-content.code'); // this behavior is not needed when viewing raw file content, so excluding it as the unnecessary dom lookups can become expensive
const el = document.querySelector(selector);
- if (!el) return () => {};
+ if (!el || exclude) return () => {};
const observer = new MutationObserver(() => addCodeButton());
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index ee5c0fe5ef3..a08cf48c327 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -15,7 +15,7 @@ $.fn.renderGFM = function renderGFM() {
syntaxHighlight(this.find('.js-syntax-highlight').get());
renderKroki(this.find('.js-render-kroki[hidden]').get());
renderMath(this.find('.js-render-math'));
- renderSandboxedMermaid(this.find('.js-render-mermaid'));
+ renderSandboxedMermaid(this.find('.js-render-mermaid').get());
renderJSONTable(
Array.from(this.find('[lang="json"][data-lang-params="table"]').get()).map((e) => e.parentNode),
);
diff --git a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
index 077e96b2fee..66007aa9e3d 100644
--- a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
@@ -1,5 +1,4 @@
-import $ from 'jquery';
-import { once, countBy } from 'lodash';
+import { countBy } from 'lodash';
import { __ } from '~/locale';
import {
getBaseURL,
@@ -8,7 +7,8 @@ import {
joinPaths,
} from '~/lib/utils/url_utility';
import { darkModeEnabled } from '~/lib/utils/color_utils';
-import { setAttributes } from '~/lib/utils/dom_utils';
+import { setAttributes, isElementVisible } from '~/lib/utils/dom_utils';
+import { createAlert, VARIANT_WARNING } from '~/flash';
import { unrestrictedPages } from './constants';
// Renders diagrams and flowcharts from text using Mermaid in any element with the
@@ -27,17 +27,30 @@ import { unrestrictedPages } from './constants';
const SANDBOX_FRAME_PATH = '/-/sandbox/mermaid';
// This is an arbitrary number; Can be iterated upon when suitable.
-const MAX_CHAR_LIMIT = 2000;
+export const MAX_CHAR_LIMIT = 2000;
// Max # of mermaid blocks that can be rendered in a page.
-const MAX_MERMAID_BLOCK_LIMIT = 50;
+export const MAX_MERMAID_BLOCK_LIMIT = 50;
// Max # of `&` allowed in Chaining of links syntax
const MAX_CHAINING_OF_LINKS_LIMIT = 30;
+
export const BUFFER_IFRAME_HEIGHT = 10;
export const SANDBOX_ATTRIBUTES = 'allow-scripts allow-popups';
+
+const ALERT_CONTAINER_CLASS = 'mermaid-alert-container';
+export const LAZY_ALERT_SHOWN_CLASS = 'lazy-alert-shown';
+
// Keep a map of mermaid blocks we've already rendered.
const elsProcessingMap = new WeakMap();
let renderedMermaidBlocks = 0;
+/**
+ * Determines whether a given Mermaid diagram is visible.
+ *
+ * @param {Element} el The Mermaid DOM node
+ * @returns
+ */
+const isVisibleMermaid = (el) => el.closest('details') === null && isElementVisible(el);
+
function shouldLazyLoadMermaidBlock(source) {
/**
* If source contains `&`, which means that it might
@@ -104,8 +117,8 @@ function renderMermaidEl(el, source) {
);
}
-function renderMermaids($els) {
- if (!$els.length) return;
+function renderMermaids(els) {
+ if (!els.length) return;
const pageName = document.querySelector('body').dataset.page;
@@ -114,7 +127,7 @@ function renderMermaids($els) {
let renderedChars = 0;
- $els.each((i, el) => {
+ els.forEach((el) => {
// Skipping all the elements which we've already queued in requestIdleCallback
if (elsProcessingMap.has(el)) {
return;
@@ -133,33 +146,29 @@ function renderMermaids($els) {
renderedMermaidBlocks >= MAX_MERMAID_BLOCK_LIMIT ||
shouldLazyLoadMermaidBlock(source))
) {
- const html = `
- <div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-mermaid-container js-lazy-render-mermaid-container fade show" role="alert">
- <div>
- <div>
- <div class="js-warning-text"></div>
- <div class="gl-alert-actions">
- <button type="button" class="js-lazy-render-mermaid btn gl-alert-action btn-confirm btn-md gl-button">Display</button>
- </div>
- </div>
- <button type="button" class="close" data-dismiss="alert" aria-label="Close">
- <span aria-hidden="true">&times;</span>
- </button>
- </div>
- </div>
- `;
-
- const $parent = $(el).parent();
-
- if (!$parent.hasClass('lazy-alert-shown')) {
- $parent.after(html);
- $parent
- .siblings()
- .find('.js-warning-text')
- .text(
- __('Warning: Displaying this diagram might cause performance issues on this page.'),
- );
- $parent.addClass('lazy-alert-shown');
+ const parent = el.parentNode;
+
+ if (!parent.classList.contains(LAZY_ALERT_SHOWN_CLASS)) {
+ const alertContainer = document.createElement('div');
+ alertContainer.classList.add(ALERT_CONTAINER_CLASS);
+ alertContainer.classList.add('gl-mb-5');
+ parent.after(alertContainer);
+ createAlert({
+ message: __(
+ 'Warning: Displaying this diagram might cause performance issues on this page.',
+ ),
+ variant: VARIANT_WARNING,
+ parent: parent.parentNode,
+ containerSelector: `.${ALERT_CONTAINER_CLASS}`,
+ primaryButton: {
+ text: __('Display'),
+ clickHandler: () => {
+ alertContainer.remove();
+ renderMermaidEl(el, source);
+ },
+ },
+ });
+ parent.classList.add(LAZY_ALERT_SHOWN_CLASS);
}
return;
@@ -176,37 +185,33 @@ function renderMermaids($els) {
});
}
-const hookLazyRenderMermaidEvent = once(() => {
- $(document.body).on('click', '.js-lazy-render-mermaid', function eventHandler() {
- const parent = $(this).closest('.js-lazy-render-mermaid-container');
- const pre = parent.prev();
-
- const el = pre.find('.js-render-mermaid');
-
- parent.remove();
-
- // sandbox update
- const element = el.get(0);
- const { source } = fixElementSource(element);
+export default function renderMermaid(els) {
+ if (!els.length) return;
- renderMermaidEl(element, source);
- });
-});
-
-export default function renderMermaid($els) {
- if (!$els.length) return;
+ const visibleMermaids = [];
+ const hiddenMermaids = [];
- const visibleMermaids = $els.filter(function filter() {
- return $(this).closest('details').length === 0 && $(this).is(':visible');
- });
+ for (const el of els) {
+ if (isVisibleMermaid(el)) {
+ visibleMermaids.push(el);
+ } else {
+ hiddenMermaids.push(el);
+ }
+ }
renderMermaids(visibleMermaids);
- $els.closest('details').one('toggle', function toggle() {
- if (this.open) {
- renderMermaids($(this).find('.js-render-mermaid'));
- }
+ hiddenMermaids.forEach((el) => {
+ el.closest('details')?.addEventListener(
+ 'toggle',
+ ({ target: details }) => {
+ if (details.open) {
+ renderMermaids([...details.querySelectorAll('.js-render-mermaid')]);
+ }
+ },
+ {
+ once: true,
+ },
+ );
});
-
- hookLazyRenderMermaidEvent();
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index 3239375bf7c..38ee02938cc 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -93,6 +93,12 @@ export const GO_TO_YOUR_MERGE_REQUESTS = {
defaultKeys: ['shift+m'],
};
+export const GO_TO_YOUR_REVIEW_REQUESTS = {
+ id: 'globalShortcuts.goToYourReviewRequests',
+ description: __('Go to your review requests'),
+ defaultKeys: ['shift+r'],
+};
+
export const GO_TO_YOUR_TODO_LIST = {
id: 'globalShortcuts.goToYourTodoList',
description: __('Go to your To-Do list'),
@@ -523,6 +529,7 @@ export const GLOBAL_SHORTCUTS_GROUP = {
FOCUS_FILTER_BAR,
GO_TO_YOUR_ISSUES,
GO_TO_YOUR_MERGE_REQUESTS,
+ GO_TO_YOUR_REVIEW_REQUESTS,
GO_TO_YOUR_TODO_LIST,
TOGGLE_PERFORMANCE_BAR,
HIDE_APPEARING_CONTENT,
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index 4d78c7b56a0..7a1577e97d5 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -24,6 +24,7 @@ import {
GO_TO_MILESTONE_LIST,
GO_TO_YOUR_SNIPPETS,
GO_TO_PROJECT_FIND_FILE,
+ GO_TO_YOUR_REVIEW_REQUESTS,
} from './keybindings';
import { disableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle';
@@ -94,6 +95,9 @@ export default class Shortcuts {
Mousetrap.bind(keysFor(GO_TO_YOUR_MERGE_REQUESTS), () =>
findAndFollowLink('.dashboard-shortcuts-merge_requests'),
);
+ Mousetrap.bind(keysFor(GO_TO_YOUR_REVIEW_REQUESTS), () =>
+ findAndFollowLink('.dashboard-shortcuts-review_requests'),
+ );
Mousetrap.bind(keysFor(GO_TO_YOUR_PROJECTS), () =>
findAndFollowLink('.dashboard-shortcuts-projects'),
);
diff --git a/app/assets/javascripts/blob/blob_blame_link.js b/app/assets/javascripts/blob/blob_blame_link.js
index 41dfd7b82b8..a5191a3d513 100644
--- a/app/assets/javascripts/blob/blob_blame_link.js
+++ b/app/assets/javascripts/blob/blob_blame_link.js
@@ -1,4 +1,6 @@
-function addBlameLink(containerSelector, linkClass) {
+import { getPageParamValue, getPageSearchString } from './utils';
+
+export function addBlameLink(containerSelector, linkClass) {
const containerEl = document.querySelector(containerSelector);
if (!containerEl) {
@@ -13,10 +15,14 @@ function addBlameLink(containerSelector, linkClass) {
lineLinkCopy.classList.remove(linkClass, 'diff-line-num');
const { lineNumber } = lineLink.dataset;
- const { blamePath } = document.querySelector('.line-numbers').dataset;
const blameLink = document.createElement('a');
+ const { blamePath } = document.querySelector('.line-numbers').dataset;
blameLink.classList.add('file-line-blame');
- blameLink.href = `${blamePath}#L${lineNumber}`;
+ const blamePerPage = document.querySelector('.js-per-page')?.dataset?.blamePerPage;
+ const pageNumber = getPageParamValue(lineNumber, blamePerPage);
+ const searchString = getPageSearchString(blamePath, pageNumber);
+
+ blameLink.href = `${blamePath}${searchString}#L${lineNumber}`;
const wrapper = document.createElement('div');
wrapper.classList.add('line-links', 'diff-line-num');
@@ -27,5 +33,3 @@ function addBlameLink(containerSelector, linkClass) {
}
});
}
-
-export default addBlameLink;
diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js
deleted file mode 100644
index 387d6043315..00000000000
--- a/app/assets/javascripts/blob/blob_file_dropzone.js
+++ /dev/null
@@ -1,96 +0,0 @@
-/* eslint-disable func-names */
-
-import Dropzone from 'dropzone';
-import $ from 'jquery';
-import { sprintf, __ } from '~/locale';
-import { HIDDEN_CLASS } from '../lib/utils/constants';
-import csrf from '../lib/utils/csrf';
-import { visitUrl } from '../lib/utils/url_utility';
-
-Dropzone.autoDiscover = false;
-
-function toggleLoading($el, $icon, loading) {
- if (loading) {
- $el.disable();
- $icon.removeClass(HIDDEN_CLASS);
- } else {
- $el.enable();
- $icon.addClass(HIDDEN_CLASS);
- }
-}
-export default class BlobFileDropzone {
- constructor(form, method) {
- const formDropzone = form.find('.dropzone');
- const submitButton = form.find('#submit-all');
- const submitButtonLoadingIcon = submitButton.find('.js-loading-icon');
- const dropzoneMessage = form.find('.dz-message');
- Dropzone.autoDiscover = false;
-
- const dropzone = formDropzone.dropzone({
- autoDiscover: false,
- autoProcessQueue: false,
- url: form.attr('action'),
- // Rails uses a hidden input field for PUT
- method,
- clickable: true,
- uploadMultiple: false,
- paramName: 'file',
- maxFilesize: gon.max_file_size || 10,
- parallelUploads: 1,
- maxFiles: 1,
- addRemoveLinks: true,
- previewsContainer: '.dropzone-previews',
- headers: csrf.headers,
- init() {
- this.on('processing', function () {
- this.options.url = form.attr('action');
- });
-
- this.on('addedfile', () => {
- toggleLoading(submitButton, submitButtonLoadingIcon, false);
- dropzoneMessage.addClass(HIDDEN_CLASS);
- $('.dropzone-alerts').html('').hide();
- });
- this.on('removedfile', () => {
- toggleLoading(submitButton, submitButtonLoadingIcon, false);
- dropzoneMessage.removeClass(HIDDEN_CLASS);
- });
- this.on('success', (header, response) => {
- $('#modal-upload-blob').modal('hide');
- visitUrl(response.filePath);
- });
- this.on('maxfilesexceeded', function (file) {
- dropzoneMessage.addClass(HIDDEN_CLASS);
- this.removeFile(file);
- });
- this.on('sending', (file, xhr, formData) => {
- formData.append('branch_name', form.find('.js-branch-name').val());
- formData.append('create_merge_request', form.find('.js-create-merge-request').val());
- formData.append('commit_message', form.find('.js-commit-message').val());
- });
- },
- // Override behavior of adding error underneath preview
- error(file, errorMessage) {
- const stripped = $('<div/>').html(errorMessage).text();
- $('.dropzone-alerts')
- .html(sprintf(__('Error uploading file: %{stripped}'), { stripped }))
- .show();
- this.removeFile(file);
- },
- });
-
- submitButton.on('click', (e) => {
- e.preventDefault();
- e.stopPropagation();
-
- if (dropzone[0].dropzone.getQueuedFiles().length === 0) {
- // eslint-disable-next-line no-alert
- alert(__('Please select a file'));
- return false;
- }
- toggleLoading(submitButton, submitButtonLoadingIcon, true);
- dropzone[0].dropzone.processQueue();
- return false;
- });
- }
-}
diff --git a/app/assets/javascripts/blob/blob_line_permalink_updater.js b/app/assets/javascripts/blob/blob_line_permalink_updater.js
index df38c5400e2..928035d7c1e 100644
--- a/app/assets/javascripts/blob/blob_line_permalink_updater.js
+++ b/app/assets/javascripts/blob/blob_line_permalink_updater.js
@@ -1,4 +1,5 @@
import { getLocationHash } from '../lib/utils/url_utility';
+import { getPageParamValue, getPageSearchString } from './utils';
const lineNumberRe = /^(L|LC)[0-9]+/;
@@ -16,7 +17,10 @@ const updateLineNumbersOnBlobPermalinks = (linksToUpdate) => {
permalinkButton.dataset.originalHref = href;
return href;
})();
- permalinkButton.setAttribute('href', `${baseHref}${hashUrlString}`);
+ const lineNum = parseInt(hash.split('L')[1], 10);
+ const page = getPageParamValue(lineNum);
+ const searchString = getPageSearchString(baseHref, page);
+ permalinkButton.setAttribute('href', `${baseHref}${searchString}${hashUrlString}`);
});
}
};
diff --git a/app/assets/javascripts/blob/components/blob_edit_content.vue b/app/assets/javascripts/blob/components/blob_edit_content.vue
deleted file mode 100644
index 0e670bbd80a..00000000000
--- a/app/assets/javascripts/blob/components/blob_edit_content.vue
+++ /dev/null
@@ -1,66 +0,0 @@
-<script>
-import { debounce } from 'lodash';
-import { initSourceEditor } from '~/blob/utils';
-import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance/constants';
-
-import eventHub from './eventhub';
-
-export default {
- props: {
- value: {
- type: String,
- required: false,
- default: '',
- },
- fileName: {
- type: String,
- required: false,
- default: '',
- },
- // This is used to help uniquely create a monaco model
- // even if two blob's share a file path.
- fileGlobalId: {
- type: String,
- required: false,
- default: '',
- },
- },
- data() {
- return {
- editor: null,
- };
- },
- watch: {
- fileName(newVal) {
- this.editor.updateModelLanguage(newVal);
- },
- },
- mounted() {
- this.editor = initSourceEditor({
- el: this.$refs.editor,
- blobPath: this.fileName,
- blobContent: this.value,
- blobGlobalId: this.fileGlobalId,
- });
-
- this.editor.onDidChangeModelContent(debounce(this.onFileChange.bind(this), 250));
-
- eventHub.$emit(SNIPPET_MEASURE_BLOBS_CONTENT);
- },
- beforeDestroy() {
- this.editor.dispose();
- },
- methods: {
- onFileChange() {
- this.$emit('input', this.editor.getValue());
- },
- },
-};
-</script>
-<template>
- <div class="file-content code">
- <div id="editor" ref="editor" data-editor-loading>
- <pre class="editor-loading-content">{{ value }}</pre>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/blob/utils.js b/app/assets/javascripts/blob/utils.js
index bbc061dd36e..b2400a4b224 100644
--- a/app/assets/javascripts/blob/utils.js
+++ b/app/assets/javascripts/blob/utils.js
@@ -1,16 +1,18 @@
-import Editor from '~/editor/source_editor';
+import { getBaseURL } from '~/lib/utils/url_utility';
-export function initSourceEditor({ el, ...args }) {
- const editor = new Editor({
- scrollbar: {
- alwaysConsumeMouseWheel: false,
- },
- });
+const blameLinesPerPage = document.querySelector('.js-per-page')?.dataset?.blamePerPage;
- return editor.createInstance({
- el,
- ...args,
- });
-}
+export const getPageParamValue = (lineNum, blamePerPage = blameLinesPerPage) => {
+ if (!blamePerPage) return '';
+ const page = Math.ceil(parseInt(lineNum, 10) / parseInt(blamePerPage, 10));
+ return page <= 1 ? '' : page;
+};
+
+export const getPageSearchString = (path, page) => {
+ if (!page) return '';
+ const url = new URL(path, getBaseURL());
+ url.searchParams.set('page', page);
+ return url.search;
+};
export default () => ({});
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 1c9c99dcc2f..4741dd53708 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -3,9 +3,8 @@
import $ from 'jquery';
import initPopover from '~/blob/suggest_gitlab_ci_yml';
import { createAlert } from '~/flash';
-import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils';
+import { setCookie } from '~/lib/utils/common_utils';
import Tracking from '~/tracking';
-import BlobFileDropzone from '../blob/blob_file_dropzone';
import NewCommitForm from '../new_commit_form';
const initPopovers = () => {
@@ -38,21 +37,8 @@ const initPopovers = () => {
}
};
-export const initUploadForm = () => {
- const uploadBlobForm = $('.js-upload-blob-form');
- if (uploadBlobForm.length) {
- const method = uploadBlobForm.data('method');
-
- new BlobFileDropzone(uploadBlobForm, method);
- new NewCommitForm(uploadBlobForm);
-
- disableButtonIfEmptyField(uploadBlobForm.find('.js-commit-message'), '.btn-upload-file');
- }
-};
-
export default () => {
const editBlobForm = $('.js-edit-blob-form');
- const deleteBlobForm = $('.js-delete-blob-form');
if (editBlobForm.length) {
const urlRoot = editBlobForm.data('relativeUrlRoot');
@@ -99,10 +85,4 @@ export default () => {
// returning here blocks page navigation
window.onbeforeunload = () => '';
}
-
- initUploadForm();
-
- if (deleteBlobForm.length) {
- new NewCommitForm(deleteBlobForm);
- }
};
diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index 78e3f934183..97d8b206307 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -16,8 +16,10 @@ export default class EditBlob {
constructor(options) {
this.options = options;
this.configureMonacoEditor();
+ this.isMarkdown = this.options.isMarkdown;
+ this.markdownLivePreviewOpened = false;
- if (this.options.isMarkdown) {
+ if (this.isMarkdown) {
this.fetchMarkdownExtension();
}
@@ -104,6 +106,13 @@ export default class EditBlob {
this.$editModeLinks.on('click', (e) => this.editModeLinkClickHandler(e));
}
+ toggleMarkdownPreview(toOpen) {
+ if (toOpen !== this.markdownLivePreviewOpened) {
+ this.editor.markdownPreview?.eventEmitter.fire();
+ this.markdownLivePreviewOpened = !this.markdownLivePreviewOpened;
+ }
+ }
+
editModeLinkClickHandler(e) {
e.preventDefault();
@@ -115,25 +124,29 @@ export default class EditBlob {
currentLink.parent().addClass('active hover');
- this.$editModePanes.hide();
-
- currentPane.show();
-
- if (paneId === '#preview') {
- this.$toggleButton.hide();
- axios
- .post(currentLink.data('previewUrl'), {
- content: this.editor.getValue(),
- })
- .then(({ data }) => {
- currentPane.empty().append(data);
- currentPane.renderGFM();
- })
- .catch(() =>
- createAlert({
- message: BLOB_PREVIEW_ERROR,
- }),
- );
+ if (this.isMarkdown) {
+ this.toggleMarkdownPreview(paneId === '#preview');
+ } else {
+ this.$editModePanes.hide();
+
+ currentPane.show();
+
+ if (paneId === '#preview') {
+ this.$toggleButton.hide();
+ axios
+ .post(currentLink.data('previewUrl'), {
+ content: this.editor.getValue(),
+ })
+ .then(({ data }) => {
+ currentPane.empty().append(data);
+ currentPane.renderGFM();
+ })
+ .catch(() =>
+ createAlert({
+ message: BLOB_PREVIEW_ERROR,
+ }),
+ );
+ }
}
this.$toggleButton.show();
diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue
index af753151be8..1335a3b108b 100644
--- a/app/assets/javascripts/boards/components/board_app.vue
+++ b/app/assets/javascripts/boards/components/board_app.vue
@@ -11,7 +11,7 @@ export default {
BoardSettingsSidebar,
BoardTopBar,
},
- inject: ['disabled'],
+ inject: ['disabled', 'fullBoardId'],
computed: {
...mapGetters(['isSidebarOpen']),
},
@@ -27,7 +27,7 @@ export default {
<template>
<div class="boards-app gl-relative" :class="{ 'is-compact': isSidebarOpen }">
<board-top-bar />
- <board-content :disabled="disabled" />
+ <board-content :disabled="disabled" :board-id="fullBoardId" />
<board-settings-sidebar />
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index 44c16324950..f3307977be9 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -109,6 +109,8 @@ export default {
:update-filters="true"
:index="index"
:show-work-item-type-icon="showWorkItemTypeIcon"
- />
+ >
+ <slot></slot>
+ </board-card-inner>
</li>
</template>
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index 3a2b11a649d..05c786ca61d 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -15,7 +15,6 @@ import { updateHistory } from '~/lib/utils/url_utility';
import { sprintf, __, n__ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
-import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import IssuableBlockedIcon from '~/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue';
import { ListType } from '../constants';
@@ -36,7 +35,6 @@ export default {
IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
IssuableBlockedIcon,
GlSprintf,
- BoardCardMoveToPosition,
WorkItemTypeIcon,
IssueHealthStatus: () =>
import('ee_component/related_items_tree/components/issue_health_status.vue'),
@@ -45,7 +43,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
mixins: [boardCardInner],
- inject: ['rootPath', 'scopedLabelsAvailable'],
+ inject: ['rootPath', 'scopedLabelsAvailable', 'isEpicBoard'],
props: {
item: {
type: Object,
@@ -80,7 +78,7 @@ export default {
},
computed: {
...mapState(['isShowingLabels', 'issuableType', 'allowSubEpics']),
- ...mapGetters(['isEpicBoard', 'isProjectBoard']),
+ ...mapGetters(['isProjectBoard']),
cappedAssignees() {
// e.g. maxRender is 4,
// Render up to all 4 assignees if there are only 4 assigness
@@ -250,8 +248,7 @@ export default {
>{{ item.title }}</a
>
</h4>
- <!-- TODO: remove the condition when https://gitlab.com/gitlab-org/gitlab/-/issues/377862 is resolved -->
- <board-card-move-to-position v-if="!isEpicBoard" :item="item" :list="list" :index="index" />
+ <slot></slot>
</div>
<div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap">
<template v-for="label in orderedLabels">
diff --git a/app/assets/javascripts/boards/components/board_card_move_to_position.vue b/app/assets/javascripts/boards/components/board_card_move_to_position.vue
index ff938219475..706b453e868 100644
--- a/app/assets/javascripts/boards/components/board_card_move_to_position.vue
+++ b/app/assets/javascripts/boards/components/board_card_move_to_position.vue
@@ -1,6 +1,6 @@
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { mapActions, mapGetters, mapState } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
@@ -31,10 +31,13 @@ export default {
type: Number,
required: true,
},
+ listItemsLength: {
+ type: Number,
+ required: true,
+ },
},
computed: {
...mapState(['pageInfoByListId']),
- ...mapGetters(['getBoardItemsByList']),
tracking() {
return {
category: 'boards:list',
@@ -42,15 +45,9 @@ export default {
property: `type_card`,
};
},
- listItems() {
- return this.getBoardItemsByList(this.list.id);
- },
listHasNextPage() {
return this.pageInfoByListId[this.list.id]?.hasNextPage;
},
- lengthOfListItemsInBoard() {
- return this.listItems?.length;
- },
itemIdentifier() {
return `${this.item.id}-${this.item.iid}-${this.index}`;
},
@@ -58,7 +55,7 @@ export default {
return this.index === 0;
},
isLastItemInList() {
- return this.index === this.lengthOfListItemsInBoard - 1;
+ return this.index === this.listItemsLength - 1;
},
},
methods: {
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index d99afa8455d..150378f7a7d 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -1,14 +1,21 @@
<script>
import { GlAlert } from '@gitlab/ui';
-import { sortBy } from 'lodash';
+import { sortBy, throttle } from 'lodash';
import Draggable from 'vuedraggable';
import { mapState, mapGetters, mapActions } from 'vuex';
+import { s__ } from '~/locale';
+import { formatBoardLists } from 'ee_else_ce/boards/boards_util';
import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
import { defaultSortableOptions } from '~/sortable/constants';
-import { DraggableItemTypes } from '../constants';
+import { DraggableItemTypes, BoardType, listsQuery } 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,
@@ -19,21 +26,78 @@ export default {
EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'),
GlAlert,
},
- inject: ['canAdminList'],
+ inject: [
+ 'canAdminList',
+ 'boardType',
+ 'fullPath',
+ 'issuableType',
+ 'isIssueBoard',
+ 'isEpicBoard',
+ 'isApolloBoard',
+ ],
props: {
disabled: {
type: Boolean,
required: true,
},
+ boardId: {
+ type: String,
+ required: true,
+ },
+ },
+ 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', 'isEpicBoard', 'isIssueBoard']),
+ ...mapGetters(['isSwimlanesOn']),
addColumnFormVisible() {
return this.addColumnForm?.visible;
},
+ queryVariables() {
+ return {
+ ...(this.isIssueBoard && {
+ isGroup: this.boardType === BoardType.group,
+ isProject: this.boardType === BoardType.project,
+ }),
+ fullPath: this.fullPath,
+ boardId: this.boardId,
+ filterParams: this.filterParams,
+ };
+ },
boardListsToUse() {
- return sortBy([...Object.values(this.boardLists)], 'position');
+ const lists = this.isApolloBoard ? this.boardListsApollo : this.boardLists;
+ return sortBy([...Object.values(lists)], 'position');
},
canDragColumns() {
return this.canAdminList;
@@ -54,6 +118,22 @@ export default {
return this.canDragColumns ? options : {};
},
+ errorToDisplay() {
+ return this.isApolloBoard ? this.apolloError : this.error;
+ },
+ },
+ mounted() {
+ this.setBoardHeight();
+
+ this.resizeObserver = new ResizeObserver(
+ throttle(() => {
+ this.setBoardHeight();
+ }, 150),
+ );
+ this.resizeObserver.observe(document.body);
+ },
+ unmounted() {
+ this.resizeObserver.disconnect();
},
methods: {
...mapActions(['moveList', 'unsetError']),
@@ -61,14 +141,17 @@ export default {
const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list;
el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' });
},
+ setBoardHeight() {
+ this.boardHeight = `${window.innerHeight - this.$el.getBoundingClientRect().top}px`;
+ },
},
};
</script>
<template>
<div v-cloak data-qa-selector="boards_list">
- <gl-alert v-if="error" variant="danger" :dismissible="true" @dismiss="unsetError">
- {{ error }}
+ <gl-alert v-if="errorToDisplay" variant="danger" :dismissible="true" @dismiss="unsetError">
+ {{ errorToDisplay }}
</gl-alert>
<component
:is="boardColumnWrapper"
@@ -76,6 +159,7 @@ export default {
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 }"
@end="moveList"
>
<board-column
@@ -99,6 +183,7 @@ export default {
:lists="boardListsToUse"
:can-admin-list="canAdminList"
:disabled="disabled"
+ :style="{ height: boardHeight }"
/>
<board-content-sidebar v-if="isIssueBoard" data-testid="issue-boards-sidebar" />
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
index 11a5d89cc8c..97f52f21e7f 100644
--- a/app/assets/javascripts/boards/components/board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -6,9 +6,20 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import {
- FILTERED_SEARCH_TERM,
FILTER_ANY,
+ FILTERED_SEARCH_TERM,
+ TOKEN_TYPE_ASSIGNEE,
+ TOKEN_TYPE_AUTHOR,
+ TOKEN_TYPE_CONFIDENTIAL,
+ TOKEN_TYPE_EPIC,
TOKEN_TYPE_HEALTH,
+ TOKEN_TYPE_ITERATION,
+ TOKEN_TYPE_LABEL,
+ TOKEN_TYPE_MILESTONE,
+ TOKEN_TYPE_MY_REACTION,
+ TOKEN_TYPE_RELEASE,
+ TOKEN_TYPE_TYPE,
+ 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';
@@ -17,8 +28,6 @@ import eventHub from '../eventhub';
export default {
i18n: {
search: __('Search'),
- label: __('Label'),
- author: __('Author'),
},
components: { FilteredSearch },
inject: ['initialFilterParams'],
@@ -62,28 +71,28 @@ export default {
if (authorUsername) {
filteredSearchValue.push({
- type: 'author',
+ type: TOKEN_TYPE_AUTHOR,
value: { data: authorUsername, operator: '=' },
});
}
if (assigneeUsername) {
filteredSearchValue.push({
- type: 'assignee',
+ type: TOKEN_TYPE_ASSIGNEE,
value: { data: assigneeUsername, operator: '=' },
});
}
if (assigneeId) {
filteredSearchValue.push({
- type: 'assignee',
+ type: TOKEN_TYPE_ASSIGNEE,
value: { data: assigneeId, operator: '=' },
});
}
if (types) {
filteredSearchValue.push({
- type: 'type',
+ type: TOKEN_TYPE_TYPE,
value: { data: types, operator: '=' },
});
}
@@ -91,7 +100,7 @@ export default {
if (labelName?.length) {
filteredSearchValue.push(
...labelName.map((label) => ({
- type: 'label',
+ type: TOKEN_TYPE_LABEL,
value: { data: label, operator: '=' },
})),
);
@@ -99,7 +108,7 @@ export default {
if (milestoneTitle) {
filteredSearchValue.push({
- type: 'milestone',
+ type: TOKEN_TYPE_MILESTONE,
value: { data: milestoneTitle, operator: '=' },
});
}
@@ -116,42 +125,42 @@ export default {
if (iterationData) {
filteredSearchValue.push({
- type: 'iteration',
+ type: TOKEN_TYPE_ITERATION,
value: { data: iterationData, operator: '=' },
});
}
if (weight) {
filteredSearchValue.push({
- type: 'weight',
+ type: TOKEN_TYPE_WEIGHT,
value: { data: weight, operator: '=' },
});
}
if (myReactionEmoji) {
filteredSearchValue.push({
- type: 'my-reaction',
+ type: TOKEN_TYPE_MY_REACTION,
value: { data: myReactionEmoji, operator: '=' },
});
}
if (releaseTag) {
filteredSearchValue.push({
- type: 'release',
+ type: TOKEN_TYPE_RELEASE,
value: { data: releaseTag, operator: '=' },
});
}
if (confidential !== undefined) {
filteredSearchValue.push({
- type: 'confidential',
+ type: TOKEN_TYPE_CONFIDENTIAL,
value: { data: confidential },
});
}
if (epicId) {
filteredSearchValue.push({
- type: 'epic',
+ type: TOKEN_TYPE_EPIC,
value: { data: epicId, operator: '=' },
});
}
@@ -165,35 +174,35 @@ export default {
if (this.filterParams['not[authorUsername]']) {
filteredSearchValue.push({
- type: 'author',
+ type: TOKEN_TYPE_AUTHOR,
value: { data: this.filterParams['not[authorUsername]'], operator: '!=' },
});
}
if (this.filterParams['not[milestoneTitle]']) {
filteredSearchValue.push({
- type: 'milestone',
+ type: TOKEN_TYPE_MILESTONE,
value: { data: this.filterParams['not[milestoneTitle]'], operator: '!=' },
});
}
if (this.filterParams['not[iterationId]']) {
filteredSearchValue.push({
- type: 'iteration',
+ type: TOKEN_TYPE_ITERATION,
value: { data: this.filterParams['not[iterationId]'], operator: '!=' },
});
}
if (this.filterParams['not[weight]']) {
filteredSearchValue.push({
- type: 'weight',
+ type: TOKEN_TYPE_WEIGHT,
value: { data: this.filterParams['not[weight]'], operator: '!=' },
});
}
if (this.filterParams['not[assigneeUsername]']) {
filteredSearchValue.push({
- type: 'assignee',
+ type: TOKEN_TYPE_ASSIGNEE,
value: { data: this.filterParams['not[assigneeUsername]'], operator: '!=' },
});
}
@@ -201,7 +210,7 @@ export default {
if (this.filterParams['not[labelName]']) {
filteredSearchValue.push(
...this.filterParams['not[labelName]'].map((label) => ({
- type: 'label',
+ type: TOKEN_TYPE_LABEL,
value: { data: label, operator: '!=' },
})),
);
@@ -209,28 +218,28 @@ export default {
if (this.filterParams['not[types]']) {
filteredSearchValue.push({
- type: 'type',
+ type: TOKEN_TYPE_TYPE,
value: { data: this.filterParams['not[types]'], operator: '!=' },
});
}
if (this.filterParams['not[epicId]']) {
filteredSearchValue.push({
- type: 'epic',
+ type: TOKEN_TYPE_EPIC,
value: { data: this.filterParams['not[epicId]'], operator: '!=' },
});
}
if (this.filterParams['not[myReactionEmoji]']) {
filteredSearchValue.push({
- type: 'my-reaction',
+ type: TOKEN_TYPE_MY_REACTION,
value: { data: this.filterParams['not[myReactionEmoji]'], operator: '!=' },
});
}
if (this.filterParams['not[releaseTag]']) {
filteredSearchValue.push({
- type: 'release',
+ type: TOKEN_TYPE_RELEASE,
value: { data: this.filterParams['not[releaseTag]'], operator: '!=' },
});
}
@@ -302,7 +311,7 @@ export default {
my_reaction_emoji: myReactionEmoji,
release_tag: releaseTag,
confidential,
- [TOKEN_TYPE_HEALTH]: healthStatus,
+ health_status: healthStatus,
},
(value) => {
if (value || value === false) {
@@ -361,44 +370,44 @@ export default {
filters.forEach((filter) => {
switch (filter.type) {
- case 'author':
+ case TOKEN_TYPE_AUTHOR:
filterParams.authorUsername = filter.value.data;
break;
- case 'assignee':
+ case TOKEN_TYPE_ASSIGNEE:
if (Object.values(AssigneeFilterType).includes(filter.value.data)) {
filterParams.assigneeId = filter.value.data;
} else {
filterParams.assigneeUsername = filter.value.data;
}
break;
- case 'type':
+ case TOKEN_TYPE_TYPE:
filterParams.types = filter.value.data;
break;
- case 'label':
+ case TOKEN_TYPE_LABEL:
labels.push(filter.value.data);
break;
- case 'milestone':
+ case TOKEN_TYPE_MILESTONE:
filterParams.milestoneTitle = filter.value.data;
break;
- case 'iteration':
+ case TOKEN_TYPE_ITERATION:
filterParams.iterationId = filter.value.data;
break;
- case 'weight':
+ case TOKEN_TYPE_WEIGHT:
filterParams.weight = filter.value.data;
break;
- case 'epic':
+ case TOKEN_TYPE_EPIC:
filterParams.epicId = filter.value.data;
break;
- case 'my-reaction':
+ case TOKEN_TYPE_MY_REACTION:
filterParams.myReactionEmoji = filter.value.data;
break;
- case 'release':
+ case TOKEN_TYPE_RELEASE:
filterParams.releaseTag = filter.value.data;
break;
- case 'confidential':
+ case TOKEN_TYPE_CONFIDENTIAL:
filterParams.confidential = filter.value.data;
break;
- case 'filtered-search-term':
+ case FILTERED_SEARCH_TERM:
if (filter.value.data) plainText.push(filter.value.data);
break;
case TOKEN_TYPE_HEALTH:
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index eb889344c1e..fcf026bbe00 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -84,7 +84,7 @@ export default {
},
computed: {
...mapState(['error']),
- ...mapGetters(['isIssueBoard', 'isGroupBoard', 'isProjectBoard']),
+ ...mapGetters(['isGroupBoard', 'isProjectBoard']),
isNewForm() {
return this.currentPage === formType.new;
},
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index edf1a5ee7e6..816b22e4dc6 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -1,12 +1,13 @@
<script>
import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import Draggable from 'vuedraggable';
-import { mapActions, mapGetters, mapState } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import { sprintf, __ } from '~/locale';
import { defaultSortableOptions } from '~/sortable/constants';
import { sortableStart, sortableEnd } from '~/sortable/utils';
import Tracking from '~/tracking';
import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
+import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue';
import { toggleFormEventPrefix, DraggableItemTypes } from '../constants';
import eventHub from '../eventhub';
import BoardCard from './board_card.vue';
@@ -27,8 +28,10 @@ export default {
BoardNewEpic: () => import('ee_component/boards/components/board_new_epic.vue'),
GlLoadingIcon,
GlIntersectionObserver,
+ BoardCardMoveToPosition,
},
mixins: [Tracking.mixin()],
+ inject: ['isEpicBoard'],
props: {
disabled: {
type: Boolean,
@@ -60,6 +63,9 @@ export default {
filters: this.filterParams,
};
},
+ context: {
+ isSingleRequest: true,
+ },
skip() {
return this.isEpicBoard;
},
@@ -67,7 +73,6 @@ export default {
},
computed: {
...mapState(['pageInfoByListId', 'listsFlags', 'filterParams', 'isUpdateIssueOrderInProgress']),
- ...mapGetters(['isEpicBoard']),
listItemsCount() {
return this.isEpicBoard ? this.list.epicsCount : this.boardList?.issuesCount;
},
@@ -309,7 +314,16 @@ export default {
:data-draggable-item-type="$options.draggableItemTypes.card"
:disabled="disabled"
:show-work-item-type-icon="!isEpicBoard"
- />
+ >
+ <!-- TODO: remove the condition when https://gitlab.com/gitlab-org/gitlab/-/issues/377862 is resolved -->
+ <board-card-move-to-position
+ v-if="!isEpicBoard"
+ :item="item"
+ :index="index"
+ :list="list"
+ :list-items-length="boardItems.length"
+ />
+ </board-card>
<gl-intersection-observer @appear="onReachingListBottom">
<li
v-if="showCount"
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index 230fa4e1e0f..bfc4b52baaf 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -57,6 +57,9 @@ export default {
canCreateEpic: {
default: false,
},
+ isEpicBoard: {
+ default: false,
+ },
},
props: {
list: {
@@ -76,7 +79,7 @@ export default {
},
computed: {
...mapState(['activeId', 'filterParams', 'boardId']),
- ...mapGetters(['isEpicBoard', 'isSwimlanesOn']),
+ ...mapGetters(['isSwimlanesOn']),
isLoggedIn() {
return Boolean(this.currentUserId);
},
@@ -168,6 +171,9 @@ export default {
filters: this.filterParams,
};
},
+ context: {
+ isSingleRequest: true,
+ },
skip() {
return this.isEpicBoard;
},
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index e93edad675c..c0c2699b63d 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -31,7 +31,7 @@ export default {
GlModal: GlModalDirective,
},
mixins: [glFeatureFlagMixin(), Tracking.mixin()],
- inject: ['canAdminList', 'scopedLabelsAvailable'],
+ inject: ['canAdminList', 'scopedLabelsAvailable', 'isIssueBoard'],
inheritAttrs: false,
data() {
return {
@@ -40,10 +40,10 @@ export default {
},
modalId: 'board-settings-sidebar-modal',
computed: {
- ...mapGetters(['isSidebarOpen', 'isEpicBoard']),
+ ...mapGetters(['isSidebarOpen']),
...mapState(['activeId', 'sidebarType', 'boardLists']),
isWipLimitsOn() {
- return this.glFeatures.wipLimits && !this.isEpicBoard;
+ return this.glFeatures.wipLimits && this.isIssueBoard;
},
activeList() {
return this.boardLists[this.activeId] || {};
diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue
index 54a6e3000a4..368feba9a44 100644
--- a/app/assets/javascripts/boards/components/board_top_bar.vue
+++ b/app/assets/javascripts/boards/components/board_top_bar.vue
@@ -1,5 +1,4 @@
<script>
-import { mapGetters } from 'vuex';
import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue';
import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue';
import IssueBoardFilteredSearch from 'ee_else_ce/boards/components/issue_board_filtered_search.vue';
@@ -20,10 +19,7 @@ export default {
EpicBoardFilteredSearch: () =>
import('ee_component/boards/components/epic_filtered_search.vue'),
},
- inject: ['swimlanesFeatureAvailable', 'canAdminList', 'isSignedIn'],
- computed: {
- ...mapGetters(['isEpicBoard']),
- },
+ inject: ['swimlanesFeatureAvailable', 'canAdminList', 'isSignedIn', 'isIssueBoard'],
};
</script>
@@ -37,8 +33,8 @@ export default {
>
<boards-selector />
<new-board-button />
- <epic-board-filtered-search v-if="isEpicBoard" />
- <issue-board-filtered-search v-else />
+ <issue-board-filtered-search v-if="isIssueBoard" />
+ <epic-board-filtered-search v-else />
</div>
<div
class="filter-dropdown-container gl-md-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-align-items-flex-start"
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 bab6fe26978..605e11d1590 100644
--- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
@@ -12,9 +12,24 @@ import { TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import {
- TOKEN_TITLE_MY_REACTION,
OPERATOR_IS_AND_IS_NOT,
OPERATOR_IS_ONLY,
+ TOKEN_TITLE_ASSIGNEE,
+ TOKEN_TITLE_AUTHOR,
+ TOKEN_TITLE_CONFIDENTIAL,
+ TOKEN_TITLE_LABEL,
+ TOKEN_TITLE_MILESTONE,
+ TOKEN_TITLE_MY_REACTION,
+ TOKEN_TITLE_RELEASE,
+ TOKEN_TITLE_TYPE,
+ TOKEN_TYPE_ASSIGNEE,
+ TOKEN_TYPE_AUTHOR,
+ TOKEN_TYPE_CONFIDENTIAL,
+ TOKEN_TYPE_LABEL,
+ TOKEN_TYPE_MILESTONE,
+ TOKEN_TYPE_MY_REACTION,
+ TOKEN_TYPE_RELEASE,
+ TOKEN_TYPE_TYPE,
} from '~/vue_shared/components/filtered_search_bar/constants';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
@@ -28,17 +43,8 @@ export default {
INCIDENT: 'INCIDENT',
},
i18n: {
- search: __('Search'),
- epic: __('Epic'),
- label: __('Label'),
- author: __('Author'),
- assignee: __('Assignee'),
- type: __('Type'),
incident: __('Incident'),
issue: __('Issue'),
- milestone: __('Milestone'),
- release: __('Release'),
- confidential: __('Confidential'),
},
components: { BoardFilteredSearch },
inject: ['isSignedIn', 'releasesFetchPath', 'fullPath', 'boardType'],
@@ -52,17 +58,7 @@ export default {
: this.fullPath.slice(0, this.fullPath.lastIndexOf('/'));
},
tokensCE() {
- const {
- label,
- author,
- assignee,
- issue,
- incident,
- type,
- milestone,
- release,
- confidential,
- } = this.$options.i18n;
+ const { issue, incident } = this.$options.i18n;
const { types } = this.$options;
const { fetchAuthors, fetchLabels } = issueBoardFilters(
this.$apollo,
@@ -73,8 +69,8 @@ export default {
const tokens = [
{
icon: 'user',
- title: assignee,
- type: 'assignee',
+ title: TOKEN_TITLE_ASSIGNEE,
+ type: TOKEN_TYPE_ASSIGNEE,
operators: OPERATOR_IS_AND_IS_NOT,
token: AuthorToken,
unique: true,
@@ -83,8 +79,8 @@ export default {
},
{
icon: 'pencil',
- title: author,
- type: 'author',
+ title: TOKEN_TITLE_AUTHOR,
+ type: TOKEN_TYPE_AUTHOR,
operators: OPERATOR_IS_AND_IS_NOT,
symbol: '@',
token: AuthorToken,
@@ -94,8 +90,8 @@ export default {
},
{
icon: 'labels',
- title: label,
- type: 'label',
+ title: TOKEN_TITLE_LABEL,
+ type: TOKEN_TYPE_LABEL,
operators: OPERATOR_IS_AND_IS_NOT,
token: LabelToken,
unique: false,
@@ -105,7 +101,7 @@ export default {
...(this.isSignedIn
? [
{
- type: 'my-reaction',
+ type: TOKEN_TYPE_MY_REACTION,
title: TOKEN_TITLE_MY_REACTION,
icon: 'thumb-up',
token: EmojiToken,
@@ -127,9 +123,9 @@ export default {
},
},
{
- type: 'confidential',
+ type: TOKEN_TYPE_CONFIDENTIAL,
icon: 'eye-slash',
- title: confidential,
+ title: TOKEN_TITLE_CONFIDENTIAL,
unique: true,
token: GlFilteredSearchToken,
operators: OPERATOR_IS_ONLY,
@@ -141,8 +137,8 @@ export default {
]
: []),
{
- type: 'milestone',
- title: milestone,
+ type: TOKEN_TYPE_MILESTONE,
+ title: TOKEN_TITLE_MILESTONE,
icon: 'clock',
symbol: '%',
token: MilestoneToken,
@@ -152,8 +148,8 @@ export default {
},
{
icon: 'issues',
- title: type,
- type: 'type',
+ title: TOKEN_TITLE_TYPE,
+ type: TOKEN_TYPE_TYPE,
token: GlFilteredSearchToken,
unique: true,
options: [
@@ -162,8 +158,8 @@ export default {
],
},
{
- type: 'release',
- title: release,
+ type: TOKEN_TYPE_RELEASE,
+ title: TOKEN_TITLE_RELEASE,
icon: 'rocket',
token: ReleaseToken,
fetchReleases: (search) => {
diff --git a/app/assets/javascripts/boards/graphql.js b/app/assets/javascripts/boards/graphql.js
deleted file mode 100644
index d066a5d002e..00000000000
--- a/app/assets/javascripts/boards/graphql.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { defaultDataIdFromObject } from '@apollo/client/core';
-import createDefaultClient from '~/lib/graphql';
-
-export const gqlClient = createDefaultClient(
- {},
- {
- cacheConfig: {
- dataIdFromObject: (object) => {
- // eslint-disable-next-line no-underscore-dangle
- return object.__typename === 'BoardList' ? object.iid : defaultDataIdFromObject(object);
- },
- },
- batchMax: 2,
- },
-);
diff --git a/app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql b/app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql
index f48383624c9..d5498f03e4b 100644
--- a/app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql
+++ b/app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql
@@ -1,4 +1,4 @@
-query BoardList($id: ListID!, $filters: BoardIssueInput) {
+query BoardListCount($id: ListID!, $filters: BoardIssueInput) {
boardList(id: $id, issueFilters: $filters) {
id
issuesCount
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index a7003edba47..f8bd81e6b98 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -12,14 +12,14 @@ import {
convertObjectPropsToCamelCase,
} from '~/lib/utils/common_utils';
import { queryToObject } from '~/lib/utils/url_utility';
+import { defaultClient } from '~/graphql_shared/issuable_client';
import { fullBoardId } from './boards_util';
-import { gqlClient } from './graphql';
Vue.use(VueApollo);
Vue.use(PortalVue);
const apolloProvider = new VueApollo({
- defaultClient: gqlClient,
+ defaultClient,
});
function mountBoardApp(el) {
@@ -53,6 +53,8 @@ function mountBoardApp(el) {
store,
apolloProvider,
provide: {
+ isApolloBoard: window.gon?.features?.apolloBoards,
+ fullBoardId: fullBoardId(boardId),
disabled: parseBoolean(el.dataset.disabled),
groupId: Number(groupId),
rootPath,
@@ -70,6 +72,8 @@ function mountBoardApp(el) {
emailsDisabled: parseBoolean(el.dataset.emailsDisabled),
hasMissingBoards: parseBoolean(el.dataset.hasMissingBoards),
weights: el.dataset.weights ? JSON.parse(el.dataset.weights) : [],
+ isIssueBoard: true,
+ isEpicBoard: false,
// Permissions
canUpdate: parseBoolean(el.dataset.canUpdate),
canAdminList: parseBoolean(el.dataset.canAdminList),
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index c2e346da606..e5437690fd4 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -34,11 +34,11 @@ import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mut
import totalCountAndWeightQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
import { fetchPolicies } from '~/lib/graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { queryToObject } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import eventHub from '../eventhub';
-import { gqlClient } from '../graphql';
import projectBoardQuery from '../graphql/project_board.query.graphql';
import groupBoardQuery from '../graphql/group_board.query.graphql';
import boardLabelsQuery from '../graphql/board_labels.query.graphql';
@@ -149,6 +149,9 @@ export default {
query: listsQuery[issuableType].query,
variables,
...(resetLists ? { fetchPolicy: fetchPolicies.NO_CACHE } : {}),
+ context: {
+ isSingleRequest: true,
+ },
})
.then(({ data }) => {
const { lists, hideBacklogList } = data[boardType].board;
diff --git a/app/assets/javascripts/branches/components/delete_merged_branches.vue b/app/assets/javascripts/branches/components/delete_merged_branches.vue
new file mode 100644
index 00000000000..70974f2e725
--- /dev/null
+++ b/app/assets/javascripts/branches/components/delete_merged_branches.vue
@@ -0,0 +1,171 @@
+<script>
+import { GlButton, GlFormInput, GlModal, GlSprintf, GlTooltipDirective } 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}.',
+ ),
+ notVisibleBranchesWarning: s__(
+ 'Branches|This may include merged branches that are not visible on the current screen.',
+ ),
+ protectedBranchWarning: s__(
+ "Branches|A branch won't be deleted if it is protected or associated with an open merge request.",
+ ),
+ permanentEffectWarning: s__(
+ 'Branches|This bulk action is %{strongStart}permanent and cannot be undone or recovered%{strongEnd}.',
+ ),
+ confirmationMessage: s__(
+ 'Branches|Plese type the following to confirm: %{codeStart}delete%{codeEnd}.',
+ ),
+ cancelButtonText: __('Cancel'),
+};
+
+export default {
+ csrf,
+ components: {
+ GlModal,
+ GlButton,
+ GlFormInput,
+ GlSprintf,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ formPath: {
+ type: String,
+ required: true,
+ },
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ areAllBranchesVisible: false,
+ enteredText: '',
+ };
+ },
+ computed: {
+ buttonTooltipText() {
+ return sprintf(this.$options.i18n.buttonTooltipText, { defaultBranch: this.defaultBranch });
+ },
+ modalMessage() {
+ return sprintf(this.$options.i18n.modalMessage, {
+ defaultBranch: this.defaultBranch,
+ });
+ },
+ isDeletingConfirmed() {
+ return this.enteredText.trim().toLowerCase() === 'delete';
+ },
+ isDeleteButtonDisabled() {
+ return !this.isDeletingConfirmed;
+ },
+ },
+ methods: {
+ openModal() {
+ this.$refs.modal.show();
+ },
+ submitForm() {
+ if (!this.isDeleteButtonDisabled) {
+ this.$refs.form.submit();
+ }
+ },
+ closeModal() {
+ this.$refs.modal.hide();
+ },
+ },
+ i18n,
+};
+</script>
+
+<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-modal
+ ref="modal"
+ size="sm"
+ modal-id="delete-merged-branches"
+ :title="$options.i18n.modalTitle"
+ >
+ <form ref="form" :action="formPath" method="post" @submit.prevent>
+ <p>
+ <gl-sprintf :message="modalMessage">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p>
+ {{ $options.i18n.notVisibleBranchesWarning }}
+ </p>
+ <p>
+ {{ $options.i18n.protectedBranchWarning }}
+ </p>
+ <p>
+ <gl-sprintf :message="$options.i18n.permanentEffectWarning">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p>
+ <gl-sprintf :message="$options.i18n.confirmationMessage">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ <gl-form-input
+ v-model="enteredText"
+ data-qa-selector="delete_merged_branches_input"
+ type="text"
+ size="sm"
+ class="gl-mt-2"
+ aria-labelledby="input-label"
+ autocomplete="off"
+ @keyup.enter="submitForm"
+ />
+ </p>
+
+ <input ref="method" type="hidden" name="_method" value="delete" />
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ </form>
+
+ <template #modal-footer>
+ <div
+ class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0 gl-mr-3"
+ >
+ <gl-button data-testid="delete-merged-branches-cancel-button" @click="closeModal">
+ {{ $options.i18n.cancelButtonText }}
+ </gl-button>
+ <gl-button
+ ref="deleteMergedBrancesButton"
+ :disabled="isDeleteButtonDisabled"
+ variant="danger"
+ data-qa-selector="delete_merged_branches_confirmation_button"
+ data-testid="delete-merged-branches-confirmation-button"
+ @click="submitForm"
+ >{{ $options.i18n.deleteButtonText }}</gl-button
+ >
+ </div>
+ </template>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/branches/init_delete_merged_branches.js b/app/assets/javascripts/branches/init_delete_merged_branches.js
new file mode 100644
index 00000000000..998db07d8de
--- /dev/null
+++ b/app/assets/javascripts/branches/init_delete_merged_branches.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import DeleteMergedBranches from '~/branches/components/delete_merged_branches.vue';
+
+export default function initDeleteMergedBranchesModal() {
+ const el = document.querySelector('.js-delete-merged-branches');
+ if (!el) {
+ return false;
+ }
+
+ const { formPath, defaultBranch } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createComponent) {
+ return createComponent(DeleteMergedBranches, {
+ props: {
+ formPath,
+ defaultBranch,
+ },
+ });
+ },
+ });
+}
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
new file mode 100644
index 00000000000..16bfc7f3abe
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/delete_pipeline_schedule_modal.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+
+export default {
+ modal: {
+ id: 'delete-pipeline-schedule-modal',
+ deleteConfirmation: s__(
+ 'PipelineSchedules|Are you sure you want to delete this pipeline schedule?',
+ ),
+ actionPrimary: {
+ text: s__('PipelineSchedules|Delete pipeline schedule'),
+ attributes: [{ variant: 'danger' }],
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ attributes: [],
+ },
+ },
+ components: {
+ GlModal,
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ :visible="visible"
+ :title="$options.modal.actionPrimary.text"
+ :modal-id="$options.modal.id"
+ :action-primary="$options.modal.actionPrimary"
+ :action-cancel="$options.modal.actionCancel"
+ size="sm"
+ @primary="$emit('deleteSchedule')"
+ @hide="$emit('hideModal')"
+ >
+ {{ $options.modal.deleteConfirmation }}
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue
new file mode 100644
index 00000000000..fe16cb7a92e
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue
@@ -0,0 +1,256 @@
+<script>
+import { GlAlert, GlBadge, GlButton, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import { limitedCounterWithDelimiter } from '~/lib/utils/text_utility';
+import { queryToObject } from '~/lib/utils/url_utility';
+import deletePipelineScheduleMutation from '../graphql/mutations/delete_pipeline_schedule.mutation.graphql';
+import takeOwnershipMutation from '../graphql/mutations/take_ownership.mutation.graphql';
+import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql';
+import PipelineSchedulesTable from './table/pipeline_schedules_table.vue';
+import TakeOwnershipModal from './take_ownership_modal.vue';
+import DeletePipelineScheduleModal from './delete_pipeline_schedule_modal.vue';
+
+export default {
+ i18n: {
+ schedulesFetchError: s__('PipelineSchedules|There was a problem fetching pipeline schedules.'),
+ scheduleDeleteError: s__(
+ 'PipelineSchedules|There was a problem deleting the pipeline schedule.',
+ ),
+ takeOwnershipError: s__(
+ 'PipelineSchedules|There was a problem taking ownership of the pipeline schedule.',
+ ),
+ newSchedule: s__('PipelineSchedules|New schedule'),
+ deleteSuccess: s__('PipelineSchedules|Pipeline schedule successfully deleted.'),
+ },
+ components: {
+ DeletePipelineScheduleModal,
+ GlAlert,
+ GlBadge,
+ GlButton,
+ GlLoadingIcon,
+ GlTabs,
+ GlTab,
+ PipelineSchedulesTable,
+ TakeOwnershipModal,
+ },
+ inject: {
+ fullPath: {
+ default: '',
+ },
+ },
+ apollo: {
+ schedules: {
+ query: getPipelineSchedulesQuery,
+ variables() {
+ return {
+ projectPath: this.fullPath,
+ status: this.scope,
+ };
+ },
+ update(data) {
+ const { pipelineSchedules: { nodes: list = [], count } = {} } = data.project || {};
+
+ return {
+ list,
+ count,
+ };
+ },
+ error() {
+ this.reportError(this.$options.i18n.schedulesFetchError);
+ },
+ },
+ },
+ data() {
+ const { scope } = queryToObject(window.location.search);
+ return {
+ schedules: {
+ list: [],
+ },
+ scope,
+ hasError: false,
+ errorMessage: '',
+ scheduleId: null,
+ showDeleteModal: false,
+ showTakeOwnershipModal: false,
+ count: 0,
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.schedules.loading;
+ },
+ schedulesCount() {
+ return this.schedules.count;
+ },
+ tabs() {
+ return [
+ {
+ text: s__('PipelineSchedules|All'),
+ count: limitedCounterWithDelimiter(this.count),
+ scope: null,
+ showBadge: true,
+ attrs: { 'data-testid': 'pipeline-schedules-all-tab' },
+ },
+ {
+ text: s__('PipelineSchedules|Active'),
+ scope: 'ACTIVE',
+ showBadge: false,
+ attrs: { 'data-testid': 'pipeline-schedules-active-tab' },
+ },
+ {
+ text: s__('PipelineSchedules|Inactive'),
+ scope: 'INACTIVE',
+ showBadge: false,
+ attrs: { 'data-testid': 'pipeline-schedules-inactive-tab' },
+ },
+ ];
+ },
+ },
+ watch: {
+ // this watcher ensures that the count on the all tab
+ // is not updated when switching to other tabs
+ schedulesCount(newCount) {
+ if (!this.scope) {
+ this.count = newCount;
+ }
+ },
+ },
+ methods: {
+ reportError(error) {
+ this.hasError = true;
+ this.errorMessage = error;
+ },
+ setDeleteModal(id) {
+ this.showDeleteModal = true;
+ this.scheduleId = id;
+ },
+ setTakeOwnershipModal(id) {
+ this.showTakeOwnershipModal = true;
+ this.scheduleId = id;
+ },
+ hideModal() {
+ this.showDeleteModal = false;
+ this.showTakeOwnershipModal = false;
+ this.scheduleId = null;
+ },
+ async deleteSchedule() {
+ try {
+ const {
+ data: {
+ pipelineScheduleDelete: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: deletePipelineScheduleMutation,
+ variables: { id: this.scheduleId },
+ });
+
+ if (errors.length > 0) {
+ throw new Error();
+ } else {
+ this.$apollo.queries.schedules.refetch();
+ this.$toast.show(this.$options.i18n.deleteSuccess);
+ }
+ } catch {
+ this.reportError(this.$options.i18n.scheduleDeleteError);
+ }
+ },
+ async takeOwnership() {
+ try {
+ const {
+ data: {
+ pipelineScheduleTakeOwnership: { pipelineSchedule, errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: takeOwnershipMutation,
+ variables: { id: this.scheduleId },
+ });
+
+ if (errors.length > 0) {
+ throw new Error();
+ } else {
+ this.$apollo.queries.schedules.refetch();
+
+ if (pipelineSchedule?.owner?.name) {
+ const toastMsg = sprintf(
+ s__('PipelineSchedules|Successfully taken ownership from %{owner}.'),
+ {
+ owner: pipelineSchedule.owner.name,
+ },
+ );
+
+ this.$toast.show(toastMsg);
+ }
+ }
+ } catch {
+ this.reportError(this.$options.i18n.takeOwnershipError);
+ }
+ },
+ fetchPipelineSchedulesByStatus(scope) {
+ this.scope = scope;
+ this.$apollo.queries.schedules.refetch();
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-alert v-if="hasError" class="gl-mb-2" variant="danger" @dismiss="hasError = false">
+ {{ errorMessage }}
+ </gl-alert>
+
+ <template v-else>
+ <gl-tabs
+ sync-active-tab-with-query-params
+ query-param-name="scope"
+ nav-class="gl-flex-grow-1 gl-align-items-center"
+ >
+ <gl-tab
+ v-for="tab in tabs"
+ :key="tab.text"
+ :title-link-attributes="tab.attrs"
+ :query-param-value="tab.scope"
+ @click="fetchPipelineSchedulesByStatus(tab.scope)"
+ >
+ <template #title>
+ <span>{{ tab.text }}</span>
+
+ <template v-if="tab.showBadge">
+ <gl-loading-icon v-if="tab.scope === scope && isLoading" class="gl-ml-2" />
+
+ <gl-badge v-else-if="tab.count" size="sm" class="gl-tab-counter-badge">
+ {{ tab.count }}
+ </gl-badge>
+ </template>
+ </template>
+
+ <gl-loading-icon v-if="isLoading" size="lg" />
+ <pipeline-schedules-table
+ v-else
+ :schedules="schedules.list"
+ @showTakeOwnershipModal="setTakeOwnershipModal"
+ @showDeleteModal="setDeleteModal"
+ />
+ </gl-tab>
+
+ <template #tabs-end>
+ <gl-button variant="confirm" class="gl-ml-auto" data-testid="new-schedule-button">
+ {{ $options.i18n.newSchedule }}
+ </gl-button>
+ </template>
+ </gl-tabs>
+
+ <take-ownership-modal
+ :visible="showTakeOwnershipModal"
+ @takeOwnership="takeOwnership"
+ @hideModal="hideModal"
+ />
+
+ <delete-pipeline-schedule-modal
+ :visible="showDeleteModal"
+ @deleteSchedule="deleteSchedule"
+ @hideModal="hideModal"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue
index 6e24ac6b8d4..6e24ac6b8d4 100644
--- a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_form.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue
diff --git a/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue
index 76d118bf52d..8656e5d3536 100644
--- a/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue
@@ -50,6 +50,8 @@ export default {
v-gl-tooltip
:title="$options.i18n.takeOwnershipTooltip"
icon="user"
+ data-testid="take-ownership-pipeline-schedule-btn"
+ @click="$emit('showTakeOwnershipModal', schedule.id)"
/>
<gl-button v-if="canUpdate" v-gl-tooltip :title="$options.i18n.editTooltip" icon="pencil" />
<gl-button
diff --git a/app/assets/javascripts/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 216796b357c..216796b357c 100644
--- a/app/assets/javascripts/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
diff --git a/app/assets/javascripts/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..48d59bf6e7c 100644
--- a/app/assets/javascripts/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
diff --git a/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_owner.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner.vue
index e7fa94eb7fc..e7fa94eb7fc 100644
--- a/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_owner.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner.vue
diff --git a/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue
index 08efa794bcc..08efa794bcc 100644
--- a/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue
diff --git a/app/assets/javascripts/pipeline_schedules/components/table/pipeline_schedules_table.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue
index d54008b81b2..1b97a35a51e 100644
--- a/app/assets/javascripts/pipeline_schedules/components/table/pipeline_schedules_table.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue
@@ -12,31 +12,37 @@ export default {
{
key: 'description',
label: s__('PipelineSchedules|Description'),
+ thClass: 'gl-border-t-none!',
columnClass: 'gl-w-40p',
},
{
key: 'target',
label: s__('PipelineSchedules|Target'),
+ thClass: 'gl-border-t-none!',
columnClass: 'gl-w-10p',
},
{
key: 'pipeline',
label: s__('PipelineSchedules|Last Pipeline'),
+ thClass: 'gl-border-t-none!',
columnClass: 'gl-w-10p',
},
{
key: 'next',
label: s__('PipelineSchedules|Next Run'),
+ thClass: 'gl-border-t-none!',
columnClass: 'gl-w-15p',
},
{
key: 'owner',
label: s__('PipelineSchedules|Owner'),
+ thClass: 'gl-border-t-none!',
columnClass: 'gl-w-10p',
},
{
key: 'actions',
label: '',
+ thClass: 'gl-border-t-none!',
columnClass: 'gl-w-15p',
},
],
@@ -88,6 +94,7 @@ export default {
<template #cell(actions)="{ item }">
<pipeline-schedule-actions
:schedule="item"
+ @showTakeOwnershipModal="$emit('showTakeOwnershipModal', $event)"
@showDeleteModal="$emit('showDeleteModal', $event)"
/>
</template>
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
new file mode 100644
index 00000000000..3ac52d4735d
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+
+export default {
+ modalId: 'pipeline-take-ownership-modal',
+ i18n: {
+ takeOwnership: s__('PipelineSchedules|Take ownership'),
+ ownershipMessage: s__(
+ 'PipelineSchedules|Only the owner of a pipeline schedule can make changes to it. Do you want to take ownership of this schedule?',
+ ),
+ cancelLabel: __('Cancel'),
+ },
+ components: {
+ GlModal,
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ actionCancel() {
+ return { text: this.$options.i18n.cancelLabel };
+ },
+ actionPrimary() {
+ return {
+ text: this.$options.i18n.takeOwnership,
+ attributes: [
+ {
+ variant: 'confirm',
+ category: 'primary',
+ },
+ ],
+ };
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ :visible="visible"
+ :modal-id="$options.modalId"
+ :action-primary="actionPrimary"
+ :action-cancel="actionCancel"
+ :title="$options.i18n.takeOwnership"
+ size="sm"
+ @primary="$emit('takeOwnership')"
+ @hide="$emit('hideModal')"
+ >
+ <p>{{ $options.i18n.ownershipMessage }}</p>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/pipeline_schedules/components/take_ownership_modal.vue b/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue
index 7ded3945a32..7ded3945a32 100644
--- a/app/assets/javascripts/pipeline_schedules/components/take_ownership_modal.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue
diff --git a/app/assets/javascripts/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql
index 8aab0b3fbde..8aab0b3fbde 100644
--- a/app/assets/javascripts/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql
+++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql
diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/take_ownership.mutation.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/take_ownership.mutation.graphql
new file mode 100644
index 00000000000..e410ef91d8b
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/take_ownership.mutation.graphql
@@ -0,0 +1,12 @@
+mutation takeOwnership($id: CiPipelineScheduleID!) {
+ pipelineScheduleTakeOwnership(input: { id: $id }) {
+ pipelineSchedule {
+ id
+ owner {
+ id
+ name
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql
index 7d9d658b1b6..9f6cb429cca 100644
--- a/app/assets/javascripts/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql
+++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql
@@ -1,7 +1,8 @@
-query getPipelineSchedulesQuery($projectPath: ID!) {
+query getPipelineSchedulesQuery($projectPath: ID!, $status: PipelineScheduleStatus) {
project(fullPath: $projectPath) {
id
- pipelineSchedules {
+ pipelineSchedules(status: $status) {
+ count
nodes {
id
description
diff --git a/app/assets/javascripts/pipeline_schedules/mount_pipeline_schedules_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js
index 8f77e06c19a..4c06fa321e5 100644
--- a/app/assets/javascripts/pipeline_schedules/mount_pipeline_schedules_app.js
+++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js
@@ -1,9 +1,11 @@
+import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import PipelineSchedules from './components/pipeline_schedules.vue';
Vue.use(VueApollo);
+Vue.use(GlToast);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
diff --git a/app/assets/javascripts/pipeline_schedules/mount_pipeline_schedules_form_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js
index d83417ab84a..d83417ab84a 100644
--- a/app/assets/javascripts/pipeline_schedules/mount_pipeline_schedules_form_app.js
+++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js
diff --git a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
index 9fa4b521ebc..9fa4b521ebc 100644
--- a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
diff --git a/app/assets/javascripts/runner/admin_runner_show/index.js b/app/assets/javascripts/ci/runner/admin_runner_show/index.js
index ea455416648..ea455416648 100644
--- a/app/assets/javascripts/runner/admin_runner_show/index.js
+++ b/app/assets/javascripts/ci/runner/admin_runner_show/index.js
diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
index dbaabb35cde..2915e460085 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
@@ -4,18 +4,17 @@ import { createAlert } from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { upgradeStatusTokenConfig } from 'ee_else_ce/runner/components/search_tokens/upgrade_status_token_config';
+import { upgradeStatusTokenConfig } from 'ee_else_ce/ci/runner/components/search_tokens/upgrade_status_token_config';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
fromSearchToVariables,
isSearchFiltered,
-} from 'ee_else_ce/runner/runner_search_utils';
-import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.graphql';
-import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_count.query.graphql';
+} from 'ee_else_ce/ci/runner/runner_search_utils';
+import allRunnersQuery from 'ee_else_ce/ci/runner/graphql/list/all_runners.query.graphql';
+import allRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/all_runners_count.query.graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
-import RunnerStackedLayoutBanner from '../components/runner_stacked_layout_banner.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerListEmptyState from '../components/runner_list_empty_state.vue';
@@ -41,7 +40,6 @@ export default {
components: {
GlLink,
RegistrationDropdown,
- RunnerStackedLayoutBanner,
RunnerFilteredSearchBar,
RunnerList,
RunnerListEmptyState,
@@ -162,8 +160,6 @@ export default {
</script>
<template>
<div>
- <runner-stacked-layout-banner />
-
<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"
>
diff --git a/app/assets/javascripts/runner/admin_runners/index.js b/app/assets/javascripts/ci/runner/admin_runners/index.js
index 7bb6cd5689e..c6db7148eb1 100644
--- a/app/assets/javascripts/runner/admin_runners/index.js
+++ b/app/assets/javascripts/ci/runner/admin_runners/index.js
@@ -2,7 +2,7 @@ import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { visitUrl } from '~/lib/utils/url_utility';
-import { updateOutdatedUrl } from '~/runner/runner_search_utils';
+import { updateOutdatedUrl } from '~/ci/runner/runner_search_utils';
import createDefaultClient from '~/lib/graphql';
import { createLocalState } from '../graphql/list/local_state';
import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage';
diff --git a/app/assets/javascripts/runner/components/cells/link_cell.vue b/app/assets/javascripts/ci/runner/components/cells/link_cell.vue
index 2843ddbacaf..2843ddbacaf 100644
--- a/app/assets/javascripts/runner/components/cells/link_cell.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/link_cell.vue
diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_actions_cell.vue
index 13f520c4edb..13f520c4edb 100644
--- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_actions_cell.vue
diff --git a/app/assets/javascripts/runner/components/cells/runner_owner_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue
index cb43760b2d6..cb43760b2d6 100644
--- a/app/assets/javascripts/runner/components/cells/runner_owner_cell.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue
diff --git a/app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue
index e5d49eb7c8e..1e44d5fccc2 100644
--- a/app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue
@@ -26,7 +26,7 @@ export default {
RunnerTags,
RunnerTypeBadge,
RunnerUpgradeStatusIcon: () =>
- import('ee_component/runner/components/runner_upgrade_status_icon.vue'),
+ import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'),
TooltipOnTruncate,
},
directives: {
diff --git a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue
index eb98d4ae2fb..67b9b0a266f 100644
--- a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue
@@ -8,7 +8,7 @@ export default {
components: {
RunnerStatusBadge,
RunnerUpgradeStatusBadge: () =>
- import('ee_component/runner/components/runner_upgrade_status_badge.vue'),
+ import('ee_component/ci/runner/components/runner_upgrade_status_badge.vue'),
RunnerPausedBadge,
},
directives: {
diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_field.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue
index 1bbbd55089a..1bbbd55089a 100644
--- a/app/assets/javascripts/runner/components/cells/runner_summary_field.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue
diff --git a/app/assets/javascripts/runner/components/registration/registration_dropdown.vue b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue
index 212ad5fa5a0..212ad5fa5a0 100644
--- a/app/assets/javascripts/runner/components/registration/registration_dropdown.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue
diff --git a/app/assets/javascripts/runner/components/registration/registration_token.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue
index 6b4e6a929b7..6b4e6a929b7 100644
--- a/app/assets/javascripts/runner/components/registration/registration_token.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue
diff --git a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue
index 667cb0090b3..6740065e860 100644
--- a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue
@@ -4,8 +4,8 @@ import { createAlert } from '~/flash';
import { TYPE_GROUP, TYPE_PROJECT } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
-import runnersRegistrationTokenResetMutation from '~/runner/graphql/list/runners_registration_token_reset.mutation.graphql';
-import { captureException } from '~/runner/sentry_utils';
+import runnersRegistrationTokenResetMutation from '~/ci/runner/graphql/list/runners_registration_token_reset.mutation.graphql';
+import { captureException } from '~/ci/runner/sentry_utils';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants';
const i18n = {
diff --git a/app/assets/javascripts/runner/components/runner_assigned_item.vue b/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue
index 2fa87bdd776..2fa87bdd776 100644
--- a/app/assets/javascripts/runner/components/runner_assigned_item.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue
diff --git a/app/assets/javascripts/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue
index 703da01d9c8..1ec3f8da7c3 100644
--- a/app/assets/javascripts/runner/components/runner_bulk_delete.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue
@@ -117,31 +117,34 @@ export default {
const { errors, deletedIds } = data.bulkRunnerDelete;
if (errors?.length) {
- this.onError(new Error(errors.join(' ')));
- this.$refs.modal.hide();
- return;
+ createAlert({
+ message: s__(
+ 'Runners|An error occurred while deleting. Some runners may not have been deleted.',
+ ),
+ captureError: true,
+ error: new Error(errors.join(' ')),
+ });
}
- this.$emit('deleted', {
- message: this.toastConfirmationMessage(deletedIds.length),
- });
+ if (deletedIds?.length) {
+ this.$emit('deleted', {
+ message: this.toastConfirmationMessage(deletedIds.length),
+ });
- // Clean up
-
- // Remove deleted runners from the cache
- deletedIds.forEach((id) => {
- const cacheId = cache.identify({ __typename: RUNNER_TYPENAME, id });
- cache.evict({ id: cacheId });
- });
- cache.gc();
-
- this.$refs.modal.hide();
+ // Remove deleted runners from the cache
+ deletedIds.forEach((id) => {
+ const cacheId = cache.identify({ __typename: RUNNER_TYPENAME, id });
+ cache.evict({ id: cacheId });
+ });
+ cache.gc();
+ }
},
});
} catch (error) {
this.onError(error);
} finally {
this.isDeleting = false;
+ this.$refs.modal.hide();
}
},
onError(error) {
diff --git a/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue b/app/assets/javascripts/ci/runner/components/runner_bulk_delete_checkbox.vue
index 75afb7a00bc..75afb7a00bc 100644
--- a/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_bulk_delete_checkbox.vue
diff --git a/app/assets/javascripts/runner/components/runner_delete_button.vue b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
index b4f022a7d14..32d4076b00f 100644
--- a/app/assets/javascripts/runner/components/runner_delete_button.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
@@ -1,9 +1,9 @@
<script>
import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
-import runnerDeleteMutation from '~/runner/graphql/shared/runner_delete.mutation.graphql';
+import runnerDeleteMutation from '~/ci/runner/graphql/shared/runner_delete.mutation.graphql';
import { createAlert } from '~/flash';
-import { sprintf } from '~/locale';
-import { captureException } from '~/runner/sentry_utils';
+import { sprintf, s__ } from '~/locale';
+import { captureException } from '~/ci/runner/sentry_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { I18N_DELETE_RUNNER, I18N_DELETED_TOAST } from '../constants';
import RunnerDeleteModal from './runner_delete_modal.vue';
@@ -122,8 +122,11 @@ export default {
onError(error) {
this.deleting = false;
const { message } = error;
+ const title = sprintf(s__('Runner|Runner %{runnerName} failed to delete'), {
+ runnerName: this.runnerName,
+ });
- createAlert({ message });
+ createAlert({ title, message });
captureException({ error, component: this.$options.name });
},
},
diff --git a/app/assets/javascripts/runner/components/runner_delete_modal.vue b/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue
index 8be216a7eb5..8be216a7eb5 100644
--- a/app/assets/javascripts/runner/components/runner_delete_modal.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue
diff --git a/app/assets/javascripts/runner/components/runner_detail.vue b/app/assets/javascripts/ci/runner/components/runner_detail.vue
index c260670b517..c260670b517 100644
--- a/app/assets/javascripts/runner/components/runner_detail.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_detail.vue
diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/ci/runner/components/runner_details.vue
index 3d72abcd393..6eba8f2e49f 100644
--- a/app/assets/javascripts/runner/components/runner_details.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_details.vue
@@ -18,13 +18,13 @@ export default {
HelpPopover,
RunnerDetail,
RunnerMaintenanceNoteDetail: () =>
- import('ee_component/runner/components/runner_maintenance_note_detail.vue'),
+ import('ee_component/ci/runner/components/runner_maintenance_note_detail.vue'),
RunnerGroups,
RunnerProjects,
RunnerUpgradeStatusBadge: () =>
- import('ee_component/runner/components/runner_upgrade_status_badge.vue'),
+ import('ee_component/ci/runner/components/runner_upgrade_status_badge.vue'),
RunnerUpgradeStatusAlert: () =>
- import('ee_component/runner/components/runner_upgrade_status_alert.vue'),
+ import('ee_component/ci/runner/components/runner_upgrade_status_alert.vue'),
RunnerTags,
TimeAgo,
},
diff --git a/app/assets/javascripts/runner/components/runner_edit_button.vue b/app/assets/javascripts/ci/runner/components/runner_edit_button.vue
index 33e0acaf5c0..33e0acaf5c0 100644
--- a/app/assets/javascripts/runner/components/runner_edit_button.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_edit_button.vue
diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue
index da59de9a9eb..ee56fea8282 100644
--- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue
@@ -2,7 +2,7 @@
import { cloneDeep } from 'lodash';
import { __ } from '~/locale';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
-import { searchValidator } from '~/runner/runner_search_utils';
+import { searchValidator } from '~/ci/runner/runner_search_utils';
import { CREATED_DESC, CREATED_ASC, CONTACTED_DESC, CONTACTED_ASC } from '../constants';
const sortOptions = [
diff --git a/app/assets/javascripts/runner/components/runner_groups.vue b/app/assets/javascripts/ci/runner/components/runner_groups.vue
index c3b35bd52a9..c3b35bd52a9 100644
--- a/app/assets/javascripts/runner/components/runner_groups.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_groups.vue
diff --git a/app/assets/javascripts/runner/components/runner_header.vue b/app/assets/javascripts/ci/runner/components/runner_header.vue
index 874c234ca4c..874c234ca4c 100644
--- a/app/assets/javascripts/runner/components/runner_header.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_header.vue
diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/ci/runner/components/runner_jobs.vue
index 9003eba3636..9003eba3636 100644
--- a/app/assets/javascripts/runner/components/runner_jobs.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_jobs.vue
diff --git a/app/assets/javascripts/runner/components/runner_jobs_table.vue b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue
index 7817577bab0..efa7909c913 100644
--- a/app/assets/javascripts/runner/components/runner_jobs_table.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue
@@ -2,8 +2,9 @@
import { GlTableLite } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { durationTimeFormatted } from '~/lib/utils/datetime_utility';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
-import RunnerTags from '~/runner/components/runner_tags.vue';
+import RunnerTags from '~/ci/runner/components/runner_tags.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { tableField } from '../utils';
import LinkCell from './cells/link_cell.vue';
@@ -47,6 +48,14 @@ export default {
commitPath(job) {
return job.commitPath;
},
+ duration(job) {
+ const { duration } = job;
+ return duration ? durationTimeFormatted(duration) : '';
+ },
+ queued(job) {
+ const { queuedDuration } = job;
+ return queuedDuration ? durationTimeFormatted(queuedDuration) : '';
+ },
},
fields: [
tableField({ key: 'status', label: s__('Job|Status') }),
@@ -54,6 +63,8 @@ export default {
tableField({ key: 'project', label: __('Project') }),
tableField({ key: 'commit', label: __('Commit') }),
tableField({ key: 'finished_at', label: s__('Job|Finished at') }),
+ tableField({ key: 'duration', label: s__('Job|Duration') }),
+ tableField({ key: 'queued', label: s__('Job|Queued') }),
tableField({ key: 'tags', label: s__('Runners|Tags') }),
],
};
@@ -84,12 +95,20 @@ export default {
<link-cell :href="commitPath(item)"> {{ commitShortSha(item) }}</link-cell>
</template>
- <template #cell(tags)="{ item = {} }">
- <runner-tags :tag-list="item.tags" />
- </template>
-
<template #cell(finished_at)="{ item = {} }">
<time-ago v-if="item.finishedAt" :time="item.finishedAt" />
</template>
+
+ <template #cell(duration)="{ item = {} }">
+ {{ duration(item) }}
+ </template>
+
+ <template #cell(queued)="{ item = {} }">
+ {{ queued(item) }}
+ </template>
+
+ <template #cell(tags)="{ item = {} }">
+ <runner-tags :tag-list="item.tags" />
+ </template>
</gl-table-lite>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/ci/runner/components/runner_list.vue
index e895537dcdc..e895537dcdc 100644
--- a/app/assets/javascripts/runner/components/runner_list.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_list.vue
diff --git a/app/assets/javascripts/runner/components/runner_list_empty_state.vue b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue
index e6576c83e69..e6576c83e69 100644
--- a/app/assets/javascripts/runner/components/runner_list_empty_state.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue
diff --git a/app/assets/javascripts/runner/components/runner_membership_toggle.vue b/app/assets/javascripts/ci/runner/components/runner_membership_toggle.vue
index 2b37b1cc797..2b37b1cc797 100644
--- a/app/assets/javascripts/runner/components/runner_membership_toggle.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_membership_toggle.vue
diff --git a/app/assets/javascripts/runner/components/runner_name.vue b/app/assets/javascripts/ci/runner/components/runner_name.vue
index d4ecfd2d776..d4ecfd2d776 100644
--- a/app/assets/javascripts/runner/components/runner_name.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_name.vue
diff --git a/app/assets/javascripts/runner/components/runner_pagination.vue b/app/assets/javascripts/ci/runner/components/runner_pagination.vue
index a5bf3074dd1..a5bf3074dd1 100644
--- a/app/assets/javascripts/runner/components/runner_pagination.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_pagination.vue
diff --git a/app/assets/javascripts/runner/components/runner_pause_button.vue b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue
index 334e5f6023a..2c80518e772 100644
--- a/app/assets/javascripts/runner/components/runner_pause_button.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue
@@ -1,8 +1,8 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import runnerToggleActiveMutation from '~/runner/graphql/shared/runner_toggle_active.mutation.graphql';
+import runnerToggleActiveMutation from '~/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql';
import { createAlert } from '~/flash';
-import { captureException } from '~/runner/sentry_utils';
+import { captureException } from '~/ci/runner/sentry_utils';
import { I18N_PAUSE, I18N_PAUSE_TOOLTIP, I18N_RESUME, I18N_RESUME_TOOLTIP } from '../constants';
export default {
diff --git a/app/assets/javascripts/runner/components/runner_paused_badge.vue b/app/assets/javascripts/ci/runner/components/runner_paused_badge.vue
index 00fd84a48d8..00fd84a48d8 100644
--- a/app/assets/javascripts/runner/components/runner_paused_badge.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_paused_badge.vue
diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/ci/runner/components/runner_projects.vue
index 84008e8eee8..84008e8eee8 100644
--- a/app/assets/javascripts/runner/components/runner_projects.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_projects.vue
diff --git a/app/assets/javascripts/runner/components/runner_status_badge.vue b/app/assets/javascripts/ci/runner/components/runner_status_badge.vue
index d084408781e..d084408781e 100644
--- a/app/assets/javascripts/runner/components/runner_status_badge.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_status_badge.vue
diff --git a/app/assets/javascripts/runner/components/runner_status_popover.vue b/app/assets/javascripts/ci/runner/components/runner_status_popover.vue
index 5b22f7828a1..06174d39a59 100644
--- a/app/assets/javascripts/runner/components/runner_status_popover.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_status_popover.vue
@@ -12,7 +12,7 @@ import {
I18N_STATUS_POPOVER_OFFLINE_DESCRIPTION,
I18N_STATUS_POPOVER_STALE,
I18N_STATUS_POPOVER_STALE_DESCRIPTION,
-} from '~/runner/constants';
+} from '~/ci/runner/constants';
export default {
name: 'RunnerStatusPopover',
diff --git a/app/assets/javascripts/runner/components/runner_tag.vue b/app/assets/javascripts/ci/runner/components/runner_tag.vue
index 6ad2023a866..6ad2023a866 100644
--- a/app/assets/javascripts/runner/components/runner_tag.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_tag.vue
diff --git a/app/assets/javascripts/runner/components/runner_tags.vue b/app/assets/javascripts/ci/runner/components/runner_tags.vue
index 38e566f9f53..38e566f9f53 100644
--- a/app/assets/javascripts/runner/components/runner_tags.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_tags.vue
diff --git a/app/assets/javascripts/runner/components/runner_type_badge.vue b/app/assets/javascripts/ci/runner/components/runner_type_badge.vue
index f568f914004..f568f914004 100644
--- a/app/assets/javascripts/runner/components/runner_type_badge.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_type_badge.vue
diff --git a/app/assets/javascripts/runner/components/runner_type_tabs.vue b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue
index 6b9e3bf91ad..584236168ac 100644
--- a/app/assets/javascripts/runner/components/runner_type_tabs.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue
@@ -1,6 +1,6 @@
<script>
import { GlBadge, GlTabs, GlTab } from '@gitlab/ui';
-import { searchValidator } from '~/runner/runner_search_utils';
+import { searchValidator } from '~/ci/runner/runner_search_utils';
import { formatNumber } from '~/locale';
import {
INSTANCE_TYPE,
diff --git a/app/assets/javascripts/runner/components/runner_update_form.vue b/app/assets/javascripts/ci/runner/components/runner_update_form.vue
index c613e2d2467..a9790d06ca7 100644
--- a/app/assets/javascripts/runner/components/runner_update_form.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_update_form.vue
@@ -12,11 +12,11 @@ import {
import {
modelToUpdateMutationVariables,
runnerToModel,
-} from 'ee_else_ce/runner/runner_update_form_utils';
+} from 'ee_else_ce/ci/runner/runner_update_form_utils';
import { createAlert, VARIANT_SUCCESS } from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import { captureException } from '~/runner/sentry_utils';
+import { captureException } from '~/ci/runner/sentry_utils';
import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants';
import runnerUpdateMutation from '../graphql/edit/runner_update.mutation.graphql';
import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
@@ -32,9 +32,9 @@ export default {
GlFormInputGroup,
GlSkeletonLoader,
RunnerMaintenanceNoteField: () =>
- import('ee_component/runner/components/runner_maintenance_note_field.vue'),
+ import('ee_component/ci/runner/components/runner_maintenance_note_field.vue'),
RunnerUpdateCostFactorFields: () =>
- import('ee_component/runner/components/runner_update_cost_factor_fields.vue'),
+ import('ee_component/ci/runner/components/runner_update_cost_factor_fields.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
diff --git a/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js
index 97ee8ec3eef..97ee8ec3eef 100644
--- a/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js
+++ b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js
diff --git a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js
index f5c42d120fb..117a630719e 100644
--- a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
+++ b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js
@@ -1,5 +1,7 @@
-import { __ } from '~/locale';
-import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ OPERATOR_IS_ONLY,
+ TOKEN_TITLE_STATUS,
+} from '~/vue_shared/components/filtered_search_bar/constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import {
I18N_STATUS_ONLINE,
@@ -22,7 +24,7 @@ const options = [
export const statusTokenConfig = {
icon: 'status',
- title: __('Status'),
+ title: TOKEN_TITLE_STATUS,
type: PARAM_KEY_STATUS,
token: BaseToken,
unique: true,
diff --git a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue
index 6e7c41885f8..6e7c41885f8 100644
--- a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue
+++ b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue
diff --git a/app/assets/javascripts/runner/components/search_tokens/tag_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js
index fdeba714385..fdeba714385 100644
--- a/app/assets/javascripts/runner/components/search_tokens/tag_token_config.js
+++ b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js
diff --git a/app/assets/javascripts/runner/components/search_tokens/upgrade_status_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/upgrade_status_token_config.js
index 17ee7073360..17ee7073360 100644
--- a/app/assets/javascripts/runner/components/search_tokens/upgrade_status_token_config.js
+++ b/app/assets/javascripts/ci/runner/components/search_tokens/upgrade_status_token_config.js
diff --git a/app/assets/javascripts/runner/components/stat/runner_count.vue b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue
index 37c6f922f9a..4ad9259f59d 100644
--- a/app/assets/javascripts/runner/components/stat/runner_count.vue
+++ b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue
@@ -1,7 +1,7 @@
<script>
import { fetchPolicies } from '~/lib/graphql';
-import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_count.query.graphql';
-import groupRunnersCountQuery from 'ee_else_ce/runner/graphql/list/group_runners_count.query.graphql';
+import allRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/all_runners_count.query.graphql';
+import groupRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners_count.query.graphql';
import { captureException } from '../../sentry_utils';
import { INSTANCE_TYPE, GROUP_TYPE } from '../../constants';
diff --git a/app/assets/javascripts/runner/components/stat/runner_single_stat.vue b/app/assets/javascripts/ci/runner/components/stat/runner_single_stat.vue
index ae732b052ac..ae732b052ac 100644
--- a/app/assets/javascripts/runner/components/stat/runner_single_stat.vue
+++ b/app/assets/javascripts/ci/runner/components/stat/runner_single_stat.vue
diff --git a/app/assets/javascripts/runner/components/stat/runner_stats.vue b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue
index 4df59f5a0c9..3965e5551f1 100644
--- a/app/assets/javascripts/runner/components/stat/runner_stats.vue
+++ b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue
@@ -1,5 +1,5 @@
<script>
-import RunnerSingleStat from '~/runner/components/stat/runner_single_stat.vue';
+import RunnerSingleStat from '~/ci/runner/components/stat/runner_single_stat.vue';
import {
I18N_STATUS_ONLINE,
I18N_STATUS_OFFLINE,
@@ -13,7 +13,7 @@ export default {
components: {
RunnerSingleStat,
RunnerUpgradeStatusStats: () =>
- import('ee_component/runner/components/stat/runner_upgrade_status_stats.vue'),
+ import('ee_component/ci/runner/components/stat/runner_upgrade_status_stats.vue'),
},
props: {
scope: {
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js
index dfc5f0c4152..dfc5f0c4152 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/ci/runner/constants.js
diff --git a/app/assets/javascripts/runner/graphql/edit/runner_fields.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields.fragment.graphql
index b732d587d70..b732d587d70 100644
--- a/app/assets/javascripts/runner/graphql/edit/runner_fields.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields.fragment.graphql
diff --git a/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql
index 29abddf84f5..29abddf84f5 100644
--- a/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql
diff --git a/app/assets/javascripts/runner/graphql/edit/runner_form.query.graphql b/app/assets/javascripts/ci/runner/graphql/edit/runner_form.query.graphql
index 0bf66c223fc..5599c147c56 100644
--- a/app/assets/javascripts/runner/graphql/edit/runner_form.query.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/edit/runner_form.query.graphql
@@ -1,4 +1,4 @@
-#import "ee_else_ce/runner/graphql/edit/runner_fields.fragment.graphql"
+#import "ee_else_ce/ci/runner/graphql/edit/runner_fields.fragment.graphql"
query getRunnerForm($id: CiRunnerID!) {
runner(id: $id) {
diff --git a/app/assets/javascripts/runner/graphql/edit/runner_update.mutation.graphql b/app/assets/javascripts/ci/runner/graphql/edit/runner_update.mutation.graphql
index 8694a51b5a4..9469078c317 100644
--- a/app/assets/javascripts/runner/graphql/edit/runner_update.mutation.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/edit/runner_update.mutation.graphql
@@ -1,4 +1,4 @@
-#import "ee_else_ce/runner/graphql/edit/runner_fields.fragment.graphql"
+#import "ee_else_ce/ci/runner/graphql/edit/runner_fields.fragment.graphql"
# Mutation for updates from the runner form, loads
# attributes shown in the runner details.
diff --git a/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql b/app/assets/javascripts/ci/runner/graphql/list/all_runners.query.graphql
index 1160596aff3..15401c25c64 100644
--- a/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/list/all_runners.query.graphql
@@ -1,4 +1,4 @@
-#import "~/runner/graphql/list/all_runners_connection.fragment.graphql"
+#import "~/ci/runner/graphql/list/all_runners_connection.fragment.graphql"
query getAllRunners(
$before: String
diff --git a/app/assets/javascripts/runner/graphql/list/all_runners_connection.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/all_runners_connection.fragment.graphql
index 4440b8e98da..39d79df02e7 100644
--- a/app/assets/javascripts/runner/graphql/list/all_runners_connection.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/list/all_runners_connection.fragment.graphql
@@ -1,4 +1,4 @@
-#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql"
+#import "ee_else_ce/ci/runner/graphql/list/list_item.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
fragment AllRunnersConnection on CiRunnerConnection {
diff --git a/app/assets/javascripts/runner/graphql/list/all_runners_count.query.graphql b/app/assets/javascripts/ci/runner/graphql/list/all_runners_count.query.graphql
index 82591b88d3e..82591b88d3e 100644
--- a/app/assets/javascripts/runner/graphql/list/all_runners_count.query.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/list/all_runners_count.query.graphql
diff --git a/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql b/app/assets/javascripts/ci/runner/graphql/list/bulk_runner_delete.mutation.graphql
index b73c016b1de..b73c016b1de 100644
--- a/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/list/bulk_runner_delete.mutation.graphql
diff --git a/app/assets/javascripts/runner/graphql/list/checked_runner_ids.query.graphql b/app/assets/javascripts/ci/runner/graphql/list/checked_runner_ids.query.graphql
index c01f1edb451..c01f1edb451 100644
--- a/app/assets/javascripts/runner/graphql/list/checked_runner_ids.query.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/list/checked_runner_ids.query.graphql
diff --git a/app/assets/javascripts/runner/graphql/list/group_runner_connection.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/group_runner_connection.fragment.graphql
index baef16a4b41..53be8fdc613 100644
--- a/app/assets/javascripts/runner/graphql/list/group_runner_connection.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/list/group_runner_connection.fragment.graphql
@@ -1,4 +1,4 @@
-#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql"
+#import "ee_else_ce/ci/runner/graphql/list/list_item.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
fragment GroupRunnerConnection on CiRunnerConnection {
diff --git a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql b/app/assets/javascripts/ci/runner/graphql/list/group_runners.query.graphql
index 95f9dd1beb9..08fd8974826 100644
--- a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/list/group_runners.query.graphql
@@ -1,4 +1,4 @@
-#import "~/runner/graphql/list/group_runner_connection.fragment.graphql"
+#import "~/ci/runner/graphql/list/group_runner_connection.fragment.graphql"
query getGroupRunners(
$groupFullPath: ID!
diff --git a/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql b/app/assets/javascripts/ci/runner/graphql/list/group_runners_count.query.graphql
index e88a2c2e7e6..e88a2c2e7e6 100644
--- a/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/list/group_runners_count.query.graphql
diff --git a/app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/list_item.fragment.graphql
index 19a5a48ea75..19a5a48ea75 100644
--- a/app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/list/list_item.fragment.graphql
diff --git a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql
index 0dff011daaa..0dff011daaa 100644
--- a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql
diff --git a/app/assets/javascripts/runner/graphql/list/local_state.js b/app/assets/javascripts/ci/runner/graphql/list/local_state.js
index e0477c660b4..ab53bfdbd5b 100644
--- a/app/assets/javascripts/runner/graphql/list/local_state.js
+++ b/app/assets/javascripts/ci/runner/graphql/list/local_state.js
@@ -8,7 +8,7 @@ import typeDefs from './typedefs.graphql';
* Usage:
*
* ```
- * import { createLocalState } from '~/runner/graphql/list/local_state';
+ * import { createLocalState } from '~/ci/runner/graphql/list/local_state';
*
* // initialize local state
* const { cacheConfig, typeDefs, localMutations } = createLocalState();
diff --git a/app/assets/javascripts/runner/graphql/list/runners_registration_token_reset.mutation.graphql b/app/assets/javascripts/ci/runner/graphql/list/runners_registration_token_reset.mutation.graphql
index 9c2797732ad..9c2797732ad 100644
--- a/app/assets/javascripts/runner/graphql/list/runners_registration_token_reset.mutation.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/list/runners_registration_token_reset.mutation.graphql
diff --git a/app/assets/javascripts/runner/graphql/list/typedefs.graphql b/app/assets/javascripts/ci/runner/graphql/list/typedefs.graphql
index 24e9e20cc8c..24e9e20cc8c 100644
--- a/app/assets/javascripts/runner/graphql/list/typedefs.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/list/typedefs.graphql
diff --git a/app/assets/javascripts/runner/graphql/shared/runner_delete.mutation.graphql b/app/assets/javascripts/ci/runner/graphql/shared/runner_delete.mutation.graphql
index d580ea2785e..d580ea2785e 100644
--- a/app/assets/javascripts/runner/graphql/shared/runner_delete.mutation.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/shared/runner_delete.mutation.graphql
diff --git a/app/assets/javascripts/runner/graphql/shared/runner_toggle_active.mutation.graphql b/app/assets/javascripts/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql
index 9b15570dbc0..9b15570dbc0 100644
--- a/app/assets/javascripts/runner/graphql/shared/runner_toggle_active.mutation.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql
diff --git a/app/assets/javascripts/runner/graphql/show/runner.query.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner.query.graphql
index dec434b43a5..6375b4f35a4 100644
--- a/app/assets/javascripts/runner/graphql/show/runner.query.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/show/runner.query.graphql
@@ -1,4 +1,4 @@
-#import "ee_else_ce/runner/graphql/show/runner_details.fragment.graphql"
+#import "ee_else_ce/ci/runner/graphql/show/runner_details.fragment.graphql"
query getRunner($id: CiRunnerID!) {
runner(id: $id) {
diff --git a/app/assets/javascripts/runner/graphql/show/runner_details.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_details.fragment.graphql
index 2449ee0fc0f..2449ee0fc0f 100644
--- a/app/assets/javascripts/runner/graphql/show/runner_details.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/show/runner_details.fragment.graphql
diff --git a/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql
index b5689ff7687..b5689ff7687 100644
--- a/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql
diff --git a/app/assets/javascripts/runner/graphql/show/runner_jobs.query.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql
index 14585e62bf2..edfc22f644b 100644
--- a/app/assets/javascripts/runner/graphql/show/runner_jobs.query.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql
@@ -25,8 +25,10 @@ query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String,
}
shortSha
commitPath
- tags
finishedAt
+ duration
+ queuedDuration
+ tags
}
pageInfo {
...PageInfo
diff --git a/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_projects.query.graphql
index e42648b3079..e42648b3079 100644
--- a/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/show/runner_projects.query.graphql
diff --git a/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue
index 75138b1bd81..75138b1bd81 100644
--- a/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue
+++ b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue
diff --git a/app/assets/javascripts/runner/group_runner_show/index.js b/app/assets/javascripts/ci/runner/group_runner_show/index.js
index e75f337b38e..e75f337b38e 100644
--- a/app/assets/javascripts/runner/group_runner_show/index.js
+++ b/app/assets/javascripts/ci/runner/group_runner_show/index.js
diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
index 7f56d895682..91c22923075 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
@@ -3,19 +3,18 @@ import { GlLink } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
-import { upgradeStatusTokenConfig } from 'ee_else_ce/runner/components/search_tokens/upgrade_status_token_config';
+import { upgradeStatusTokenConfig } from 'ee_else_ce/ci/runner/components/search_tokens/upgrade_status_token_config';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
fromSearchToVariables,
isSearchFiltered,
-} from 'ee_else_ce/runner/runner_search_utils';
+} from 'ee_else_ce/ci/runner/runner_search_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import groupRunnersQuery from 'ee_else_ce/runner/graphql/list/group_runners.query.graphql';
-import groupRunnersCountQuery from 'ee_else_ce/runner/graphql/list/group_runners_count.query.graphql';
+import groupRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners_count.query.graphql';
+import groupRunnersQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners.query.graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
-import RunnerStackedLayoutBanner from '../components/runner_stacked_layout_banner.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerListEmptyState from '../components/runner_list_empty_state.vue';
@@ -43,7 +42,6 @@ export default {
components: {
GlLink,
RegistrationDropdown,
- RunnerStackedLayoutBanner,
RunnerFilteredSearchBar,
RunnerList,
RunnerListEmptyState,
@@ -201,8 +199,6 @@ export default {
<template>
<div>
- <runner-stacked-layout-banner />
-
<div class="gl-display-flex gl-align-items-center">
<runner-type-tabs
ref="runner-type-tabs"
@@ -250,7 +246,12 @@ export default {
:filtered-svg-path="emptyStateFilteredSvgPath"
/>
<template v-else>
- <runner-list :runners="runners.items" :loading="runnersLoading" @deleted="onDeleted">
+ <runner-list
+ :runners="runners.items"
+ :checkable="true"
+ :loading="runnersLoading"
+ @deleted="onDeleted"
+ >
<template #runner-name="{ runner }">
<gl-link :href="webUrl(runner)">
<runner-name :runner="runner" />
diff --git a/app/assets/javascripts/runner/group_runners/index.js b/app/assets/javascripts/ci/runner/group_runners/index.js
index 0e7efd2b8a1..0e7efd2b8a1 100644
--- a/app/assets/javascripts/runner/group_runners/index.js
+++ b/app/assets/javascripts/ci/runner/group_runners/index.js
diff --git a/app/assets/javascripts/runner/local_storage_alert/constants.js b/app/assets/javascripts/ci/runner/local_storage_alert/constants.js
index 69b7418f898..69b7418f898 100644
--- a/app/assets/javascripts/runner/local_storage_alert/constants.js
+++ b/app/assets/javascripts/ci/runner/local_storage_alert/constants.js
diff --git a/app/assets/javascripts/runner/local_storage_alert/save_alert_to_local_storage.js b/app/assets/javascripts/ci/runner/local_storage_alert/save_alert_to_local_storage.js
index ca7c627459a..ca7c627459a 100644
--- a/app/assets/javascripts/runner/local_storage_alert/save_alert_to_local_storage.js
+++ b/app/assets/javascripts/ci/runner/local_storage_alert/save_alert_to_local_storage.js
diff --git a/app/assets/javascripts/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..d768a06494a 100644
--- a/app/assets/javascripts/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
diff --git a/app/assets/javascripts/runner/runner_edit/index.js b/app/assets/javascripts/ci/runner/runner_edit/index.js
index 5b2ddb8f68e..5b2ddb8f68e 100644
--- a/app/assets/javascripts/runner/runner_edit/index.js
+++ b/app/assets/javascripts/ci/runner/runner_edit/index.js
diff --git a/app/assets/javascripts/runner/runner_edit/runner_edit_app.vue b/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue
index 879162916a9..879162916a9 100644
--- a/app/assets/javascripts/runner/runner_edit/runner_edit_app.vue
+++ b/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue
diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/ci/runner/runner_search_utils.js
index adc832b0600..adc832b0600 100644
--- a/app/assets/javascripts/runner/runner_search_utils.js
+++ b/app/assets/javascripts/ci/runner/runner_search_utils.js
diff --git a/app/assets/javascripts/runner/runner_update_form_utils.js b/app/assets/javascripts/ci/runner/runner_update_form_utils.js
index 3b519fa7d71..3b519fa7d71 100644
--- a/app/assets/javascripts/runner/runner_update_form_utils.js
+++ b/app/assets/javascripts/ci/runner/runner_update_form_utils.js
diff --git a/app/assets/javascripts/runner/sentry_utils.js b/app/assets/javascripts/ci/runner/sentry_utils.js
index 29de1f9adae..29de1f9adae 100644
--- a/app/assets/javascripts/runner/sentry_utils.js
+++ b/app/assets/javascripts/ci/runner/sentry_utils.js
diff --git a/app/assets/javascripts/runner/utils.js b/app/assets/javascripts/ci/runner/utils.js
index 1ca0a9e86b5..1ca0a9e86b5 100644
--- a/app/assets/javascripts/runner/utils.js
+++ b/app/assets/javascripts/ci/runner/utils.js
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue
index 8d891ff1746..719696f682e 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue
@@ -1,143 +1,36 @@
<script>
-import { createAlert } from '~/flash';
-import { __ } from '~/locale';
-import { reportMessageToSentry } from '../utils';
+import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, UPDATE_MUTATION_ACTION } from '../constants';
import getAdminVariables from '../graphql/queries/variables.query.graphql';
-import {
- ADD_MUTATION_ACTION,
- DELETE_MUTATION_ACTION,
- UPDATE_MUTATION_ACTION,
- genericMutationErrorText,
- variableFetchErrorText,
-} from '../constants';
import addAdminVariable from '../graphql/mutations/admin_add_variable.mutation.graphql';
import deleteAdminVariable from '../graphql/mutations/admin_delete_variable.mutation.graphql';
import updateAdminVariable from '../graphql/mutations/admin_update_variable.mutation.graphql';
-import CiVariableSettings from './ci_variable_settings.vue';
+import CiVariableShared from './ci_variable_shared.vue';
export default {
components: {
- CiVariableSettings,
+ CiVariableShared,
},
- inject: ['endpoint'],
- data() {
- return {
- adminVariables: [],
- hasNextPage: false,
- isInitialLoading: true,
- isLoadingMoreItems: false,
- loadingCounter: 0,
- pageInfo: {},
- };
+ mutationData: {
+ [ADD_MUTATION_ACTION]: addAdminVariable,
+ [UPDATE_MUTATION_ACTION]: updateAdminVariable,
+ [DELETE_MUTATION_ACTION]: deleteAdminVariable,
},
- apollo: {
- adminVariables: {
+ queryData: {
+ ciVariables: {
+ lookup: (data) => data?.ciVariables,
query: getAdminVariables,
- update(data) {
- return data?.ciVariables?.nodes || [];
- },
- result({ data }) {
- this.pageInfo = data?.ciVariables?.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.$options.componentName, this.$options.tooManyCallsError, {});
- }
- },
- error() {
- this.isLoadingMoreItems = false;
- this.hasNextPage = false;
- createAlert({ message: variableFetchErrorText });
- },
- watchLoading(flag) {
- if (!flag) {
- this.isInitialLoading = false;
- }
- },
- },
- },
- computed: {
- isLoading() {
- return (
- (this.$apollo.queries.adminVariables.loading && this.isInitialLoading) ||
- this.isLoadingMoreItems
- );
},
},
- methods: {
- addVariable(variable) {
- this.variableMutation(ADD_MUTATION_ACTION, variable);
- },
- deleteVariable(variable) {
- this.variableMutation(DELETE_MUTATION_ACTION, variable);
- },
- fetchMoreVariables() {
- this.isLoadingMoreItems = true;
-
- this.$apollo.queries.adminVariables.fetchMore({
- variables: {
- after: this.pageInfo.endCursor,
- },
- });
- },
- updateVariable(variable) {
- this.variableMutation(UPDATE_MUTATION_ACTION, variable);
- },
- async variableMutation(mutationAction, variable) {
- try {
- const currentMutation = this.$options.mutationData[mutationAction];
- const { data } = await this.$apollo.mutate({
- mutation: currentMutation.action,
- variables: {
- endpoint: this.endpoint,
- variable,
- },
- });
-
- if (data[currentMutation.name]?.errors?.length) {
- const { errors } = data[currentMutation.name];
- createAlert({ message: errors[0] });
- } else {
- // 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.adminVariables.refetch();
- }
- } catch {
- createAlert({ message: genericMutationErrorText });
- }
- },
- },
- componentName: 'InstanceVariables',
- i18n: {
- tooManyCallsError: __('Maximum number of variables loaded (2000)'),
- },
- mutationData: {
- [ADD_MUTATION_ACTION]: { action: addAdminVariable, name: 'addAdminVariable' },
- [UPDATE_MUTATION_ACTION]: { action: updateAdminVariable, name: 'updateAdminVariable' },
- [DELETE_MUTATION_ACTION]: { action: deleteAdminVariable, name: 'deleteAdminVariable' },
- },
};
</script>
<template>
- <ci-variable-settings
+ <ci-variable-shared
:are-scoped-variables-available="false"
- :is-loading="isLoading"
- :variables="adminVariables"
- @add-variable="addVariable"
- @delete-variable="deleteVariable"
- @update-variable="updateVariable"
+ component-name="InstanceVariables"
+ :hide-environment-scope="true"
+ :mutation-data="$options.mutationData"
+ :refetch-after-mutation="true"
+ :query-data="$options.queryData"
/>
</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue
index 4af696b8dab..c8f5ac1736d 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue
@@ -1,143 +1,53 @@
<script>
-import { createAlert } from '~/flash';
-import { __ } from '~/locale';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { reportMessageToSentry } from '../utils';
-import getGroupVariables from '../graphql/queries/group_variables.query.graphql';
import {
ADD_MUTATION_ACTION,
DELETE_MUTATION_ACTION,
GRAPHQL_GROUP_TYPE,
UPDATE_MUTATION_ACTION,
- genericMutationErrorText,
- variableFetchErrorText,
} from '../constants';
+import getGroupVariables from '../graphql/queries/group_variables.query.graphql';
import addGroupVariable from '../graphql/mutations/group_add_variable.mutation.graphql';
import deleteGroupVariable from '../graphql/mutations/group_delete_variable.mutation.graphql';
import updateGroupVariable from '../graphql/mutations/group_update_variable.mutation.graphql';
-import CiVariableSettings from './ci_variable_settings.vue';
+import CiVariableShared from './ci_variable_shared.vue';
export default {
components: {
- CiVariableSettings,
+ CiVariableShared,
},
mixins: [glFeatureFlagsMixin()],
- inject: ['endpoint', 'groupPath', 'groupId'],
- data() {
- return {
- groupVariables: [],
- hasNextPage: false,
- isLoadingMoreItems: false,
- loadingCounter: 0,
- pageInfo: {},
- };
- },
- apollo: {
- groupVariables: {
- query: getGroupVariables,
- variables() {
- return {
- fullPath: this.groupPath,
- };
- },
- update(data) {
- return data?.group?.ciVariables?.nodes || [];
- },
- result({ data }) {
- this.pageInfo = data?.group?.ciVariables?.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.$options.componentName, this.$options.tooManyCallsError, {});
- }
- },
- error() {
- this.isLoadingMoreItems = false;
- this.hasNextPage = false;
- createAlert({ message: variableFetchErrorText });
- },
- },
- },
+ inject: ['groupPath', 'groupId'],
computed: {
areScopedVariablesAvailable() {
return this.glFeatures.groupScopedCiVariables;
},
- isLoading() {
- return this.$apollo.queries.groupVariables.loading || this.isLoadingMoreItems;
- },
- },
- methods: {
- addVariable(variable) {
- this.variableMutation(ADD_MUTATION_ACTION, variable);
- },
- deleteVariable(variable) {
- this.variableMutation(DELETE_MUTATION_ACTION, variable);
- },
- fetchMoreVariables() {
- this.isLoadingMoreItems = true;
-
- this.$apollo.queries.groupVariables.fetchMore({
- variables: {
- fullPath: this.groupPath,
- after: this.pageInfo.endCursor,
- },
- });
- },
- updateVariable(variable) {
- this.variableMutation(UPDATE_MUTATION_ACTION, variable);
- },
- async variableMutation(mutationAction, variable) {
- try {
- const currentMutation = this.$options.mutationData[mutationAction];
- const { data } = await this.$apollo.mutate({
- mutation: currentMutation.action,
- variables: {
- endpoint: this.endpoint,
- fullPath: this.groupPath,
- groupId: convertToGraphQLId(GRAPHQL_GROUP_TYPE, this.groupId),
- variable,
- },
- });
-
- if (data[currentMutation.name]?.errors?.length) {
- const { errors } = data[currentMutation.name];
- createAlert({ message: errors[0] });
- }
- } catch {
- createAlert({ message: genericMutationErrorText });
- }
+ graphqlId() {
+ return convertToGraphQLId(GRAPHQL_GROUP_TYPE, this.groupId);
},
},
- componentName: 'GroupVariables',
- i18n: {
- tooManyCallsError: __('Maximum number of variables loaded (2000)'),
- },
mutationData: {
- [ADD_MUTATION_ACTION]: { action: addGroupVariable, name: 'addGroupVariable' },
- [UPDATE_MUTATION_ACTION]: { action: updateGroupVariable, name: 'updateGroupVariable' },
- [DELETE_MUTATION_ACTION]: { action: deleteGroupVariable, name: 'deleteGroupVariable' },
+ [ADD_MUTATION_ACTION]: addGroupVariable,
+ [UPDATE_MUTATION_ACTION]: updateGroupVariable,
+ [DELETE_MUTATION_ACTION]: deleteGroupVariable,
+ },
+ queryData: {
+ ciVariables: {
+ lookup: (data) => data?.group?.ciVariables,
+ query: getGroupVariables,
+ },
},
};
</script>
<template>
- <ci-variable-settings
+ <ci-variable-shared
+ :id="graphqlId"
:are-scoped-variables-available="areScopedVariablesAvailable"
- :is-loading="isLoading"
- :variables="groupVariables"
- @add-variable="addVariable"
- @delete-variable="deleteVariable"
- @update-variable="updateVariable"
+ component-name="GroupVariables"
+ :full-path="groupPath"
+ :mutation-data="$options.mutationData"
+ :query-data="$options.queryData"
/>
</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue
index 6bd549817f8..2c4818e20c1 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue
@@ -1,160 +1,55 @@
<script>
-import { createAlert } from '~/flash';
-import { __ } from '~/locale';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import getProjectEnvironments from '../graphql/queries/project_environments.query.graphql';
-import getProjectVariables from '../graphql/queries/project_variables.query.graphql';
-import { mapEnvironmentNames, reportMessageToSentry } from '../utils';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
ADD_MUTATION_ACTION,
DELETE_MUTATION_ACTION,
GRAPHQL_PROJECT_TYPE,
UPDATE_MUTATION_ACTION,
- environmentFetchErrorText,
- genericMutationErrorText,
- variableFetchErrorText,
} from '../constants';
+import getProjectEnvironments from '../graphql/queries/project_environments.query.graphql';
+import getProjectVariables from '../graphql/queries/project_variables.query.graphql';
import addProjectVariable from '../graphql/mutations/project_add_variable.mutation.graphql';
import deleteProjectVariable from '../graphql/mutations/project_delete_variable.mutation.graphql';
import updateProjectVariable from '../graphql/mutations/project_update_variable.mutation.graphql';
-import CiVariableSettings from './ci_variable_settings.vue';
+import CiVariableShared from './ci_variable_shared.vue';
export default {
components: {
- CiVariableSettings,
- },
- inject: ['endpoint', 'projectFullPath', 'projectId'],
- data() {
- return {
- hasNextPage: false,
- isLoadingMoreItems: false,
- loadingCounter: 0,
- pageInfo: {},
- projectEnvironments: [],
- projectVariables: [],
- };
- },
- apollo: {
- projectEnvironments: {
- query: getProjectEnvironments,
- variables() {
- return {
- fullPath: this.projectFullPath,
- };
- },
- update(data) {
- return mapEnvironmentNames(data?.project?.environments?.nodes);
- },
- error() {
- createAlert({ message: environmentFetchErrorText });
- },
- },
- projectVariables: {
- query: getProjectVariables,
- variables() {
- return {
- after: null,
- fullPath: this.projectFullPath,
- };
- },
- update(data) {
- return data?.project?.ciVariables?.nodes || [];
- },
- result({ data }) {
- this.pageInfo = data?.project?.ciVariables?.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.$options.componentName, this.$options.tooManyCallsError, {});
- }
- },
- error() {
- this.isLoadingMoreItems = false;
- this.hasNextPage = false;
- createAlert({ message: variableFetchErrorText });
- },
- },
+ CiVariableShared,
},
+ mixins: [glFeatureFlagsMixin()],
+ inject: ['projectFullPath', 'projectId'],
computed: {
- isLoading() {
- return (
- this.$apollo.queries.projectVariables.loading ||
- this.$apollo.queries.projectEnvironments.loading ||
- this.isLoadingMoreItems
- );
+ graphqlId() {
+ return convertToGraphQLId(GRAPHQL_PROJECT_TYPE, this.projectId);
},
},
- methods: {
- addVariable(variable) {
- this.variableMutation(ADD_MUTATION_ACTION, variable);
- },
- deleteVariable(variable) {
- this.variableMutation(DELETE_MUTATION_ACTION, variable);
- },
- fetchMoreVariables() {
- this.isLoadingMoreItems = true;
-
- this.$apollo.queries.projectVariables.fetchMore({
- variables: {
- fullPath: this.projectFullPath,
- after: this.pageInfo.endCursor,
- },
- });
- },
- updateVariable(variable) {
- this.variableMutation(UPDATE_MUTATION_ACTION, variable);
+ mutationData: {
+ [ADD_MUTATION_ACTION]: addProjectVariable,
+ [UPDATE_MUTATION_ACTION]: updateProjectVariable,
+ [DELETE_MUTATION_ACTION]: deleteProjectVariable,
+ },
+ queryData: {
+ ciVariables: {
+ lookup: (data) => data?.project?.ciVariables,
+ query: getProjectVariables,
},
- async variableMutation(mutationAction, variable) {
- try {
- const currentMutation = this.$options.mutationData[mutationAction];
- const { data } = await this.$apollo.mutate({
- mutation: currentMutation.action,
- variables: {
- endpoint: this.endpoint,
- fullPath: this.projectFullPath,
- projectId: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, this.projectId),
- variable,
- },
- });
- if (data[currentMutation.name]?.errors?.length) {
- const { errors } = data[currentMutation.name];
- createAlert({ message: errors[0] });
- }
- } catch {
- createAlert({ message: genericMutationErrorText });
- }
+ environments: {
+ lookup: (data) => data?.project?.environments,
+ query: getProjectEnvironments,
},
},
- componentName: 'ProjectVariables',
- i18n: {
- tooManyCallsError: __('Maximum number of variables loaded (2000)'),
- },
- mutationData: {
- [ADD_MUTATION_ACTION]: { action: addProjectVariable, name: 'addProjectVariable' },
- [UPDATE_MUTATION_ACTION]: { action: updateProjectVariable, name: 'updateProjectVariable' },
- [DELETE_MUTATION_ACTION]: { action: deleteProjectVariable, name: 'deleteProjectVariable' },
- },
};
</script>
<template>
- <ci-variable-settings
+ <ci-variable-shared
+ :id="graphqlId"
:are-scoped-variables-available="true"
- :environments="projectEnvironments"
- :is-loading="isLoading"
- :variables="projectVariables"
- @add-variable="addVariable"
- @delete-variable="deleteVariable"
- @update-variable="updateVariable"
+ component-name="ProjectVariables"
+ :full-path="projectFullPath"
+ :mutation-data="$options.mutationData"
+ :query-data="$options.queryData"
/>
</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
index 56c1804910a..94f8cb9e906 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
@@ -86,6 +86,11 @@ export default {
required: false,
default: () => [],
},
+ hideEnvironmentScope: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
mode: {
type: String,
required: true,
@@ -293,10 +298,11 @@ export default {
v-model="variable.value"
:state="variableValidationState"
rows="3"
- max-rows="6"
+ max-rows="10"
data-testid="pipeline-form-ci-variable-value"
data-qa-selector="ci_variable_value_field"
class="gl-font-monospace!"
+ spellcheck="false"
/>
</gl-form-group>
@@ -309,33 +315,35 @@ export default {
/>
</gl-form-group>
- <gl-form-group
- label-for="ci-variable-env"
- class="gl-w-half"
- data-testid="environment-scope"
- >
- <template #label>
- {{ __('Environment scope') }}
- <gl-link
- :title="$options.environmentScopeLinkTitle"
- :href="environmentScopeLink"
- target="_blank"
- data-testid="environment-scope-link"
- >
- <gl-icon name="question" :size="12" />
- </gl-link>
- </template>
- <ci-environments-dropdown
- v-if="areScopedVariablesAvailable"
- class="gl-w-full"
- :selected-environment-scope="variable.environmentScope"
- :environments="joinedEnvironments"
- @select-environment="setEnvironmentScope"
- @create-environment-scope="createEnvironmentScope"
- />
+ <template v-if="!hideEnvironmentScope">
+ <gl-form-group
+ label-for="ci-variable-env"
+ class="gl-w-half"
+ data-testid="environment-scope"
+ >
+ <template #label>
+ {{ __('Environment scope') }}
+ <gl-link
+ :title="$options.environmentScopeLinkTitle"
+ :href="environmentScopeLink"
+ target="_blank"
+ data-testid="environment-scope-link"
+ >
+ <gl-icon name="question" :size="12" />
+ </gl-link>
+ </template>
+ <ci-environments-dropdown
+ v-if="areScopedVariablesAvailable"
+ class="gl-w-full"
+ :selected-environment-scope="variable.environmentScope"
+ :environments="joinedEnvironments"
+ @select-environment="setEnvironmentScope"
+ @create-environment-scope="createEnvironmentScope"
+ />
- <gl-form-input v-else :value="$options.defaultScope" class="gl-w-full" readonly />
- </gl-form-group>
+ <gl-form-input v-else :value="$options.defaultScope" class="gl-w-full" readonly />
+ </gl-form-group>
+ </template>
</div>
<gl-form-group :label="__('Flags')" label-for="ci-variable-flags">
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue
deleted file mode 100644
index 605da5d9352..00000000000
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue
+++ /dev/null
@@ -1,58 +0,0 @@
-<script>
-import { GlPopover, GlButton, GlTooltipDirective } from '@gitlab/ui';
-
-export default {
- maxTextLength: 95,
- components: {
- GlPopover,
- GlButton,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- target: {
- type: String,
- required: true,
- },
- value: {
- type: String,
- required: true,
- },
- tooltipText: {
- type: String,
- required: true,
- },
- },
- computed: {
- displayValue() {
- if (this.value.length > this.$options.maxTextLength) {
- return `${this.value.substring(0, this.$options.maxTextLength)}...`;
- }
- return this.value;
- },
- },
-};
-</script>
-
-<template>
- <div id="popover-container">
- <gl-popover :target="target" placement="top" container="popover-container">
- <div
- class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-word-break-all"
- >
- <div class="ci-popover-value gl-pr-3">
- {{ displayValue }}
- </div>
- <gl-button
- v-gl-tooltip
- category="tertiary"
- icon="copy-to-clipboard"
- :title="tooltipText"
- :data-clipboard-text="value"
- :aria-label="__('Copy to clipboard')"
- />
- </div>
- </gl-popover>
- </div>
-</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue
index 81e3a983ea3..94fd6c3892c 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue
@@ -19,6 +19,11 @@ export default {
required: false,
default: () => [],
},
+ hideEnvironmentScope: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
isLoading: {
type: Boolean,
required: false,
@@ -78,6 +83,7 @@ export default {
v-if="showModal"
:are-scoped-variables-available="areScopedVariablesAvailable"
:environments="environments"
+ :hide-environment-scope="hideEnvironmentScope"
:variables="variables"
:mode="mode"
:selected-variable="selectedVariable"
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue
new file mode 100644
index 00000000000..7ee250cea98
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue
@@ -0,0 +1,232 @@
+<script>
+import { createAlert } from '~/flash';
+import { __ } from '~/locale';
+import { mapEnvironmentNames, reportMessageToSentry } from '../utils';
+import {
+ ADD_MUTATION_ACTION,
+ DELETE_MUTATION_ACTION,
+ UPDATE_MUTATION_ACTION,
+ environmentFetchErrorText,
+ genericMutationErrorText,
+ variableFetchErrorText,
+} from '../constants';
+import CiVariableSettings from './ci_variable_settings.vue';
+
+export default {
+ components: {
+ CiVariableSettings,
+ },
+ inject: ['endpoint'],
+ props: {
+ areScopedVariablesAvailable: {
+ required: true,
+ type: Boolean,
+ },
+ componentName: {
+ required: true,
+ type: String,
+ },
+ fullPath: {
+ required: false,
+ type: String,
+ default: null,
+ },
+ hideEnvironmentScope: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ id: {
+ required: false,
+ type: String,
+ default: null,
+ },
+ mutationData: {
+ required: true,
+ type: Object,
+ validator: (obj) => {
+ const hasValidKeys = Object.keys(obj).includes(
+ ADD_MUTATION_ACTION,
+ UPDATE_MUTATION_ACTION,
+ DELETE_MUTATION_ACTION,
+ );
+
+ const hasValidValues = Object.values(obj).reduce((acc, val) => {
+ return acc && typeof val === 'object';
+ }, true);
+
+ return hasValidKeys && hasValidValues;
+ },
+ },
+ refetchAfterMutation: {
+ required: false,
+ type: Boolean,
+ default: false,
+ },
+ queryData: {
+ required: true,
+ type: Object,
+ validator: (obj) => {
+ const { ciVariables, environments } = obj;
+ const hasCiVariablesKey = Boolean(ciVariables);
+ let hasCorrectEnvData = true;
+
+ const hasCorrectVariablesData =
+ typeof ciVariables?.lookup === 'function' && typeof ciVariables.query === 'object';
+
+ if (environments) {
+ hasCorrectEnvData =
+ typeof environments?.lookup === 'function' && typeof environments.query === 'object';
+ }
+
+ return hasCiVariablesKey && hasCorrectVariablesData && hasCorrectEnvData;
+ },
+ },
+ },
+ data() {
+ return {
+ ciVariables: [],
+ hasNextPage: false,
+ isInitialLoading: true,
+ isLoadingMoreItems: false,
+ loadingCounter: 0,
+ pageInfo: {},
+ };
+ },
+ apollo: {
+ ciVariables: {
+ query() {
+ return this.queryData.ciVariables.query;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath || undefined,
+ };
+ },
+ update(data) {
+ return this.queryData.ciVariables.lookup(data)?.nodes || [];
+ },
+ result({ data }) {
+ 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, {});
+ }
+ },
+ error() {
+ this.isLoadingMoreItems = false;
+ this.hasNextPage = false;
+ createAlert({ message: variableFetchErrorText });
+ },
+ watchLoading(flag) {
+ if (!flag) {
+ this.isInitialLoading = false;
+ }
+ },
+ },
+ environments: {
+ query() {
+ return this.queryData?.environments?.query || {};
+ },
+ skip() {
+ return !this.queryData?.environments?.query;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ update(data) {
+ return mapEnvironmentNames(this.queryData.environments.lookup(data)?.nodes);
+ },
+ error() {
+ createAlert({ message: environmentFetchErrorText });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return (
+ (this.$apollo.queries.ciVariables.loading && this.isInitialLoading) ||
+ this.$apollo.queries.environments.loading ||
+ this.isLoadingMoreItems
+ );
+ },
+ },
+ methods: {
+ addVariable(variable) {
+ this.variableMutation(ADD_MUTATION_ACTION, variable);
+ },
+ deleteVariable(variable) {
+ this.variableMutation(DELETE_MUTATION_ACTION, variable);
+ },
+ fetchMoreVariables() {
+ this.isLoadingMoreItems = true;
+
+ this.$apollo.queries.ciVariables.fetchMore({
+ variables: {
+ after: this.pageInfo.endCursor,
+ },
+ });
+ },
+ updateVariable(variable) {
+ this.variableMutation(UPDATE_MUTATION_ACTION, variable);
+ },
+ async variableMutation(mutationAction, variable) {
+ try {
+ const currentMutation = this.mutationData[mutationAction];
+
+ const { data } = await this.$apollo.mutate({
+ mutation: currentMutation,
+ variables: {
+ endpoint: this.endpoint,
+ fullPath: this.fullPath || undefined,
+ id: this.id || undefined,
+ variable,
+ },
+ });
+
+ 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();
+ }
+ } catch (e) {
+ createAlert({ message: genericMutationErrorText });
+ }
+ },
+ },
+ i18n: {
+ tooManyCallsError: __('Maximum number of variables loaded (2000)'),
+ },
+};
+</script>
+
+<template>
+ <ci-variable-settings
+ :are-scoped-variables-available="areScopedVariablesAvailable"
+ :hide-environment-scope="hideEnvironmentScope"
+ :is-loading="isLoading"
+ :variables="ciVariables"
+ :environments="environments"
+ @add-variable="addVariable"
+ @delete-variable="deleteVariable"
+ @update-variable="updateVariable"
+ />
+</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
index 959ef6864fb..3cdcb68e919 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
@@ -1,71 +1,49 @@
<script>
-import {
- GlButton,
- GlIcon,
- GlLoadingIcon,
- GlModalDirective,
- GlTable,
- GlTooltipDirective,
-} from '@gitlab/ui';
+import { GlButton, GlLoadingIcon, GlModalDirective, GlTable, GlTooltipDirective } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { ADD_CI_VARIABLE_MODAL_ID, variableText } from '../constants';
import { convertEnvironmentScope } from '../utils';
-import CiVariablePopover from './ci_variable_popover.vue';
export default {
modalId: ADD_CI_VARIABLE_MODAL_ID,
- trueIcon: 'mobile-issue-close',
- falseIcon: 'close',
- iconSize: 16,
fields: [
{
key: 'variableType',
label: s__('CiVariables|Type'),
- customStyle: { width: '70px' },
+ thClass: 'gl-w-10p',
},
{
key: 'key',
label: s__('CiVariables|Key'),
tdClass: 'text-plain',
sortable: true,
- customStyle: { width: '40%' },
},
{
key: 'value',
label: s__('CiVariables|Value'),
- customStyle: { width: '40%' },
+ thClass: 'gl-w-15p',
},
{
- key: 'protected',
- label: s__('CiVariables|Protected'),
- customStyle: { width: '100px' },
- },
- {
- key: 'masked',
- label: s__('CiVariables|Masked'),
- customStyle: { width: '100px' },
+ key: 'options',
+ label: s__('CiVariables|Options'),
+ thClass: 'gl-w-10p',
},
{
key: 'environmentScope',
label: s__('CiVariables|Environments'),
- customStyle: { width: '20%' },
},
{
key: 'actions',
label: '',
tdClass: 'text-right',
- customStyle: { width: '35px' },
+ thClass: 'gl-w-5p',
},
],
components: {
- CiVariablePopover,
GlButton,
- GlIcon,
GlLoadingIcon,
GlTable,
- TooltipOnTruncate,
},
directives: {
GlModalDirective,
@@ -97,6 +75,13 @@ export default {
fields() {
return this.$options.fields;
},
+ variablesWithOptions() {
+ return this.variables?.map((item, index) => ({
+ ...item,
+ options: this.getOptions(item),
+ index,
+ }));
+ },
},
methods: {
convertEnvironmentScopeValue(env) {
@@ -108,8 +93,18 @@ export default {
toggleHiddenState() {
this.areValuesHidden = !this.areValuesHidden;
},
- setSelectedVariable(variable = null) {
- this.$emit('set-selected-variable', variable);
+ setSelectedVariable(index = -1) {
+ this.$emit('set-selected-variable', this.variables[index] ?? null);
+ },
+ getOptions(item) {
+ const options = [];
+ if (item.protected) {
+ options.push(s__('CiVariables|Protected'));
+ }
+ if (item.masked) {
+ options.push(s__('CiVariables|Masked'));
+ }
+ return options.join(', ');
},
},
};
@@ -121,7 +116,7 @@ export default {
<gl-table
v-else
:fields="fields"
- :items="variables"
+ :items="variablesWithOptions"
tbody-tr-class="js-ci-variable-row"
data-qa-selector="ci_variable_table_content"
sort-by="key"
@@ -137,23 +132,22 @@ export default {
<col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" />
</template>
<template #cell(variableType)="{ item }">
- <div class="gl-pt-2">
- {{ generateTypeText(item) }}
- </div>
+ {{ generateTypeText(item) }}
</template>
<template #cell(key)="{ item }">
- <div class="gl-display-flex gl-align-items-center">
- <tooltip-on-truncate :title="item.key" truncate-target="child">
- <span
- :id="`ci-variable-key-${item.id}`"
- class="gl-display-inline-block gl-max-w-full gl-text-truncate"
- >{{ item.key }}</span
- >
- </tooltip-on-truncate>
+ <div
+ class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3"
+ >
+ <span
+ :id="`ci-variable-key-${item.id}`"
+ class="gl-display-inline-block gl-max-w-full gl-word-break-word"
+ >{{ item.key }}</span
+ >
<gl-button
v-gl-tooltip
category="tertiary"
icon="copy-to-clipboard"
+ class="gl-my-n3 gl-ml-2"
:title="__('Copy key')"
:data-clipboard-text="item.key"
:aria-label="__('Copy to clipboard')"
@@ -161,8 +155,10 @@ export default {
</div>
</template>
<template #cell(value)="{ item }">
- <div class="gl-display-flex gl-align-items-center">
- <span v-if="areValuesHidden" data-testid="hiddenValue">*********************</span>
+ <div
+ class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3"
+ >
+ <span v-if="areValuesHidden" data-testid="hiddenValue">*****</span>
<span
v-else
:id="`ci-variable-value-${item.id}`"
@@ -174,31 +170,33 @@ export default {
v-gl-tooltip
category="tertiary"
icon="copy-to-clipboard"
+ class="gl-my-n3 gl-ml-2"
:title="__('Copy value')"
:data-clipboard-text="item.value"
:aria-label="__('Copy to clipboard')"
/>
</div>
</template>
- <template #cell(protected)="{ item }">
- <gl-icon v-if="item.protected" :size="$options.iconSize" :name="$options.trueIcon" />
- <gl-icon v-else :size="$options.iconSize" :name="$options.falseIcon" />
- </template>
- <template #cell(masked)="{ item }">
- <gl-icon v-if="item.masked" :size="$options.iconSize" :name="$options.trueIcon" />
- <gl-icon v-else :size="$options.iconSize" :name="$options.falseIcon" />
+ <template #cell(options)="{ item }">
+ <span>{{ item.options }}</span>
</template>
<template #cell(environmentScope)="{ item }">
- <div class="gl-display-flex">
+ <div
+ class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3"
+ >
<span
:id="`ci-variable-env-${item.id}`"
- class="gl-display-inline-block gl-max-w-full gl-text-truncate"
+ class="gl-display-inline-block gl-max-w-full gl-word-break-word"
>{{ convertEnvironmentScopeValue(item.environmentScope) }}</span
>
- <ci-variable-popover
- :target="`ci-variable-env-${item.id}`"
- :value="convertEnvironmentScopeValue(item.environmentScope)"
- :tooltip-text="__('Copy environment')"
+ <gl-button
+ v-gl-tooltip
+ category="tertiary"
+ icon="copy-to-clipboard"
+ class="gl-my-n3 gl-ml-2"
+ :title="__('Copy environment')"
+ :data-clipboard-text="convertEnvironmentScopeValue(item.environmentScope)"
+ :aria-label="__('Copy to clipboard')"
/>
</div>
</template>
@@ -208,7 +206,7 @@ export default {
icon="pencil"
:aria-label="__('Edit')"
data-qa-selector="edit_ci_variable_button"
- @click="setSelectedVariable(item)"
+ @click="setSelectedVariable(item.index)"
/>
</template>
<template #empty>
diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_environments_dropdown.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_environments_dropdown.vue
deleted file mode 100644
index ecb39f214ec..00000000000
--- a/app/assets/javascripts/ci_variable_list/components/legacy_ci_environments_dropdown.vue
+++ /dev/null
@@ -1,81 +0,0 @@
-<script>
-import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui';
-import { mapGetters } from 'vuex';
-import { __, sprintf } from '~/locale';
-
-export default {
- name: 'CiEnvironmentsDropdown',
- components: {
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
- GlSearchBoxByType,
- },
- props: {
- value: {
- type: String,
- required: false,
- default: '',
- },
- },
- data() {
- return {
- searchTerm: '',
- };
- },
- computed: {
- ...mapGetters(['joinedEnvironments']),
- composedCreateButtonLabel() {
- return sprintf(__('Create wildcard: %{searchTerm}'), { searchTerm: this.searchTerm });
- },
- shouldRenderCreateButton() {
- return this.searchTerm && !this.joinedEnvironments.includes(this.searchTerm);
- },
- filteredResults() {
- const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
- return this.joinedEnvironments.filter((resultString) =>
- resultString.toLowerCase().includes(lowerCasedSearchTerm),
- );
- },
- },
- methods: {
- selectEnvironment(selected) {
- this.$emit('selectEnvironment', selected);
- this.searchTerm = '';
- },
- createClicked() {
- this.$emit('createClicked', this.searchTerm);
- this.searchTerm = '';
- },
- isSelected(env) {
- return this.value === env;
- },
- clearSearch() {
- this.searchTerm = '';
- },
- },
-};
-</script>
-<template>
- <gl-dropdown :text="value" @show="clearSearch">
- <gl-search-box-by-type v-model.trim="searchTerm" data-testid="ci-environment-search" />
- <gl-dropdown-item
- v-for="environment in filteredResults"
- :key="environment"
- :is-checked="isSelected(environment)"
- is-check-item
- @click="selectEnvironment(environment)"
- >
- {{ environment }}
- </gl-dropdown-item>
- <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{
- __('No matching results')
- }}</gl-dropdown-item>
- <template v-if="shouldRenderCreateButton">
- <gl-dropdown-divider />
- <gl-dropdown-item data-testid="create-wildcard-button" @click="createClicked">
- {{ composedCreateButtonLabel }}
- </gl-dropdown-item>
- </template>
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue
deleted file mode 100644
index 1fbe52388c9..00000000000
--- a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue
+++ /dev/null
@@ -1,428 +0,0 @@
-<script>
-import {
- GlAlert,
- GlButton,
- GlCollapse,
- GlFormCheckbox,
- GlFormCombobox,
- GlFormGroup,
- GlFormSelect,
- GlFormInput,
- GlFormTextarea,
- GlIcon,
- GlLink,
- GlModal,
- GlSprintf,
-} from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
-import { getCookie, setCookie } from '~/lib/utils/common_utils';
-import { __ } from '~/locale';
-import Tracking from '~/tracking';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { mapComputed } from '~/vuex_shared/bindings';
-import {
- AWS_TOKEN_CONSTANTS,
- ADD_CI_VARIABLE_MODAL_ID,
- AWS_TIP_DISMISSED_COOKIE_NAME,
- AWS_TIP_MESSAGE,
- CONTAINS_VARIABLE_REFERENCE_MESSAGE,
- ENVIRONMENT_SCOPE_LINK_TITLE,
- EVENT_LABEL,
- EVENT_ACTION,
-} from '../constants';
-import LegacyCiEnvironmentsDropdown from './legacy_ci_environments_dropdown.vue';
-import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens';
-
-const trackingMixin = Tracking.mixin({ label: EVENT_LABEL });
-
-export default {
- modalId: ADD_CI_VARIABLE_MODAL_ID,
- tokens: awsTokens,
- tokenList: awsTokenList,
- awsTipMessage: AWS_TIP_MESSAGE,
- containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE,
- environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE,
- components: {
- LegacyCiEnvironmentsDropdown,
- GlAlert,
- GlButton,
- GlCollapse,
- GlFormCheckbox,
- GlFormCombobox,
- GlFormGroup,
- GlFormSelect,
- GlFormInput,
- GlFormTextarea,
- GlIcon,
- GlLink,
- GlModal,
- GlSprintf,
- },
- mixins: [glFeatureFlagsMixin(), trackingMixin],
- data() {
- return {
- isTipDismissed: getCookie(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true',
- validationErrorEventProperty: '',
- };
- },
- computed: {
- ...mapState([
- 'projectId',
- 'environments',
- 'typeOptions',
- 'variable',
- 'variableBeingEdited',
- 'isGroup',
- 'maskableRegex',
- 'selectedEnvironment',
- 'isProtectedByDefault',
- 'awsLogoSvgPath',
- 'awsTipDeployLink',
- 'awsTipCommandsLink',
- 'awsTipLearnLink',
- 'containsVariableReferenceLink',
- 'protectedEnvironmentVariablesLink',
- 'maskedEnvironmentVariablesLink',
- 'environmentScopeLink',
- ]),
- ...mapComputed(
- [
- { key: 'key', updateFn: 'updateVariableKey' },
- { key: 'secret_value', updateFn: 'updateVariableValue' },
- { key: 'variable_type', updateFn: 'updateVariableType' },
- { key: 'environment_scope', updateFn: 'setEnvironmentScope' },
- { key: 'protected_variable', updateFn: 'updateVariableProtected' },
- { key: 'masked', updateFn: 'updateVariableMasked' },
- ],
- false,
- 'variable',
- ),
- isTipVisible() {
- return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key);
- },
- canSubmit() {
- return (
- this.variableValidationState &&
- this.variable.key !== '' &&
- this.variable.secret_value !== ''
- );
- },
- canMask() {
- const regex = RegExp(this.maskableRegex);
- return regex.test(this.variable.secret_value);
- },
- containsVariableReference() {
- const regex = /\$/;
- return regex.test(this.variable.secret_value);
- },
- displayMaskedError() {
- return !this.canMask && this.variable.masked;
- },
- maskedState() {
- if (this.displayMaskedError) {
- return false;
- }
- return true;
- },
- modalActionText() {
- return this.variableBeingEdited ? __('Update variable') : __('Add variable');
- },
- maskedFeedback() {
- return this.displayMaskedError ? __('This variable can not be masked.') : '';
- },
- tokenValidationFeedback() {
- const tokenSpecificFeedback = this.$options.tokens?.[this.variable.key]?.invalidMessage;
- if (!this.tokenValidationState && tokenSpecificFeedback) {
- return tokenSpecificFeedback;
- }
- return '';
- },
- tokenValidationState() {
- const validator = this.$options.tokens?.[this.variable.key]?.validation;
-
- if (validator) {
- return validator(this.variable.secret_value);
- }
-
- return true;
- },
- scopedVariablesAvailable() {
- return !this.isGroup || this.glFeatures.groupScopedCiVariables;
- },
- variableValidationFeedback() {
- return `${this.tokenValidationFeedback} ${this.maskedFeedback}`;
- },
- variableValidationState() {
- return this.variable.secret_value === '' || (this.tokenValidationState && this.maskedState);
- },
- },
- watch: {
- variable: {
- handler() {
- this.trackVariableValidationErrors();
- },
- deep: true,
- },
- },
- methods: {
- ...mapActions([
- 'addVariable',
- 'updateVariable',
- 'resetEditing',
- 'displayInputValue',
- 'clearModal',
- 'deleteVariable',
- 'setEnvironmentScope',
- 'addWildCardScope',
- 'resetSelectedEnvironment',
- 'setSelectedEnvironment',
- 'setVariableProtected',
- ]),
- dismissTip() {
- setCookie(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 });
- this.isTipDismissed = true;
- },
- deleteVarAndClose() {
- this.deleteVariable();
- this.hideModal();
- },
- hideModal() {
- this.$refs.modal.hide();
- },
- resetModalHandler() {
- if (this.variableBeingEdited) {
- this.resetEditing();
- }
-
- this.clearModal();
- this.resetSelectedEnvironment();
- this.resetValidationErrorEvents();
- },
- updateOrAddVariable() {
- if (this.variableBeingEdited) {
- this.updateVariable();
- } else {
- this.addVariable();
- }
- this.hideModal();
- },
- setVariableProtectedByDefault() {
- if (this.isProtectedByDefault && !this.variableBeingEdited) {
- this.setVariableProtected();
- }
- },
- trackVariableValidationErrors() {
- const property = this.getTrackingErrorProperty();
- if (!this.validationErrorEventProperty && property) {
- this.track(EVENT_ACTION, { property });
- this.validationErrorEventProperty = property;
- }
- },
- getTrackingErrorProperty() {
- let property;
- if (this.variable.secret_value?.length && !property) {
- if (this.displayMaskedError && this.maskableRegex?.length) {
- const supportedChars = this.maskableRegex.replace('^', '').replace(/{(\d,)}\$/, '');
- const regex = new RegExp(supportedChars, 'g');
- property = this.variable.secret_value.replace(regex, '');
- }
- if (this.containsVariableReference) {
- property = '$';
- }
- }
-
- return property;
- },
- resetValidationErrorEvents() {
- this.validationErrorEventProperty = '';
- },
- },
-};
-</script>
-
-<template>
- <gl-modal
- ref="modal"
- :modal-id="$options.modalId"
- :title="modalActionText"
- static
- lazy
- @hidden="resetModalHandler"
- @shown="setVariableProtectedByDefault"
- >
- <form>
- <gl-form-combobox
- v-model="key"
- :token-list="$options.tokenList"
- :label-text="__('Key')"
- data-testid="pipeline-form-ci-variable-key"
- data-qa-selector="ci_variable_key_field"
- />
-
- <gl-form-group
- :label="__('Value')"
- label-for="ci-variable-value"
- :state="variableValidationState"
- :invalid-feedback="variableValidationFeedback"
- >
- <gl-form-textarea
- id="ci-variable-value"
- ref="valueField"
- v-model="secret_value"
- :state="variableValidationState"
- rows="3"
- max-rows="6"
- data-testid="pipeline-form-ci-variable-value"
- data-qa-selector="ci_variable_value_field"
- class="gl-font-monospace!"
- />
- </gl-form-group>
-
- <div class="d-flex">
- <gl-form-group :label="__('Type')" label-for="ci-variable-type" class="w-50 gl-mr-5">
- <gl-form-select id="ci-variable-type" v-model="variable_type" :options="typeOptions" />
- </gl-form-group>
-
- <gl-form-group label-for="ci-variable-env" class="w-50" data-testid="environment-scope">
- <template #label>
- {{ __('Environment scope') }}
- <gl-link
- :title="$options.environmentScopeLinkTitle"
- :href="environmentScopeLink"
- target="_blank"
- data-testid="environment-scope-link"
- >
- <gl-icon name="question" :size="12" />
- </gl-link>
- </template>
- <legacy-ci-environments-dropdown
- v-if="scopedVariablesAvailable"
- class="w-100"
- :value="environment_scope"
- @selectEnvironment="setEnvironmentScope"
- @createClicked="addWildCardScope"
- />
-
- <gl-form-input v-else v-model="environment_scope" class="w-100" readonly />
- </gl-form-group>
- </div>
-
- <gl-form-group :label="__('Flags')" label-for="ci-variable-flags">
- <gl-form-checkbox
- v-model="protected_variable"
- class="mb-0"
- data-testid="ci-variable-protected-checkbox"
- >
- {{ __('Protect variable') }}
- <gl-link target="_blank" :href="protectedEnvironmentVariablesLink">
- <gl-icon name="question" :size="12" />
- </gl-link>
- <p class="gl-mt-2 text-secondary">
- {{ __('Export variable to pipelines running on protected branches and tags only.') }}
- </p>
- </gl-form-checkbox>
-
- <gl-form-checkbox
- ref="masked-ci-variable"
- v-model="masked"
- data-testid="ci-variable-masked-checkbox"
- >
- {{ __('Mask variable') }}
- <gl-link target="_blank" :href="maskedEnvironmentVariablesLink">
- <gl-icon name="question" :size="12" />
- </gl-link>
- <p class="gl-mt-2 gl-mb-0 text-secondary">
- {{ __('Variable will be masked in job logs.') }}
- <span
- :class="{
- 'bold text-plain': displayMaskedError,
- }"
- >
- {{ __('Requires values to meet regular expression requirements.') }}</span
- >
- <gl-link target="_blank" :href="maskedEnvironmentVariablesLink">{{
- __('More information')
- }}</gl-link>
- </p>
- </gl-form-checkbox>
- </gl-form-group>
- </form>
- <gl-collapse :visible="isTipVisible">
- <gl-alert
- :title="__('Deploying to AWS is easy with GitLab')"
- variant="tip"
- data-testid="aws-guidance-tip"
- @dismiss="dismissTip"
- >
- <div class="gl-display-flex gl-flex-direction-row">
- <div>
- <p>
- <gl-sprintf :message="$options.awsTipMessage">
- <template #deployLink="{ content }">
- <gl-link :href="awsTipDeployLink" target="_blank">{{ content }}</gl-link>
- </template>
- <template #commandsLink="{ content }">
- <gl-link :href="awsTipCommandsLink" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
- <p>
- <gl-button
- :href="awsTipLearnLink"
- target="_blank"
- category="secondary"
- variant="info"
- class="gl-overflow-wrap-break"
- >{{ __('Learn more about deploying to AWS') }}</gl-button
- >
- </p>
- </div>
- <img
- class="gl-mt-3"
- :alt="__('Amazon Web Services Logo')"
- :src="awsLogoSvgPath"
- height="32"
- />
- </div>
- </gl-alert>
- </gl-collapse>
- <gl-alert
- v-if="containsVariableReference"
- :title="__('Value might contain a variable reference')"
- :dismissible="false"
- variant="warning"
- data-testid="contains-variable-reference"
- >
- <gl-sprintf :message="$options.containsVariableReferenceMessage">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- <template #docsLink="{ content }">
- <gl-link :href="containsVariableReferenceLink" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </gl-alert>
- <template #modal-footer>
- <gl-button @click="hideModal">{{ __('Cancel') }}</gl-button>
- <gl-button
- v-if="variableBeingEdited"
- ref="deleteCiVariable"
- variant="danger"
- category="secondary"
- data-qa-selector="ci_variable_delete_button"
- @click="deleteVarAndClose"
- >{{ __('Delete variable') }}</gl-button
- >
- <gl-button
- ref="updateOrAddVariable"
- :disabled="!canSubmit"
- variant="confirm"
- category="primary"
- data-testid="ciUpdateOrAddVariableBtn"
- data-qa-selector="ci_variable_save_button"
- @click="updateOrAddVariable"
- >{{ modalActionText }}
- </gl-button>
- </template>
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_settings.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_settings.vue
deleted file mode 100644
index f1fe188348d..00000000000
--- a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_settings.vue
+++ /dev/null
@@ -1,32 +0,0 @@
-<script>
-import { mapState, mapActions } from 'vuex';
-import LegacyCiVariableModal from './legacy_ci_variable_modal.vue';
-import LegacyCiVariableTable from './legacy_ci_variable_table.vue';
-
-export default {
- components: {
- LegacyCiVariableModal,
- LegacyCiVariableTable,
- },
- computed: {
- ...mapState(['isGroup', 'isProject']),
- },
- mounted() {
- if (this.isProject) {
- this.fetchEnvironments();
- }
- },
- methods: {
- ...mapActions(['fetchEnvironments']),
- },
-};
-</script>
-
-<template>
- <div class="row">
- <div class="col-lg-12">
- <legacy-ci-variable-table />
- <legacy-ci-variable-modal />
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_table.vue
deleted file mode 100644
index f078234829a..00000000000
--- a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_table.vue
+++ /dev/null
@@ -1,199 +0,0 @@
-<script>
-import { GlTable, GlButton, GlModalDirective, GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
-import { s__, __ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
-import { ADD_CI_VARIABLE_MODAL_ID } from '../constants';
-import CiVariablePopover from './ci_variable_popover.vue';
-
-export default {
- modalId: ADD_CI_VARIABLE_MODAL_ID,
- trueIcon: 'mobile-issue-close',
- falseIcon: 'close',
- iconSize: 16,
- fields: [
- {
- key: 'variable_type',
- label: s__('CiVariables|Type'),
- customStyle: { width: '70px' },
- },
- {
- key: 'key',
- label: s__('CiVariables|Key'),
- tdClass: 'text-plain',
- sortable: true,
- customStyle: { width: '40%' },
- },
- {
- key: 'value',
- label: s__('CiVariables|Value'),
- customStyle: { width: '40%' },
- },
- {
- key: 'protected',
- label: s__('CiVariables|Protected'),
- customStyle: { width: '100px' },
- },
- {
- key: 'masked',
- label: s__('CiVariables|Masked'),
- customStyle: { width: '100px' },
- },
- {
- key: 'environment_scope',
- label: s__('CiVariables|Environments'),
- customStyle: { width: '20%' },
- },
- {
- key: 'actions',
- label: '',
- tdClass: 'text-right',
- customStyle: { width: '35px' },
- },
- ],
- components: {
- CiVariablePopover,
- GlButton,
- GlIcon,
- GlTable,
- TooltipOnTruncate,
- },
- directives: {
- GlModalDirective,
- GlTooltip: GlTooltipDirective,
- },
- mixins: [glFeatureFlagsMixin()],
- computed: {
- ...mapState(['variables', 'valuesHidden', 'isLoading', 'isDeleting']),
- valuesButtonText() {
- return this.valuesHidden ? __('Reveal values') : __('Hide values');
- },
- isTableEmpty() {
- return !this.variables || this.variables.length === 0;
- },
- fields() {
- return this.$options.fields;
- },
- },
- mounted() {
- this.fetchVariables();
- },
- methods: {
- ...mapActions(['fetchVariables', 'toggleValues', 'editVariable']),
- },
-};
-</script>
-
-<template>
- <div class="ci-variable-table" data-testid="ci-variable-table">
- <gl-table
- :fields="fields"
- :items="variables"
- tbody-tr-class="js-ci-variable-row"
- data-qa-selector="ci_variable_table_content"
- sort-by="key"
- sort-direction="asc"
- stacked="lg"
- table-class="text-secondary"
- fixed
- show-empty
- sort-icon-left
- no-sort-reset
- >
- <template #table-colgroup="scope">
- <col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" />
- </template>
- <template #cell(key)="{ item }">
- <div class="gl-display-flex gl-align-items-center">
- <tooltip-on-truncate :title="item.key" truncate-target="child">
- <span
- :id="`ci-variable-key-${item.id}`"
- class="gl-display-inline-block gl-max-w-full gl-text-truncate"
- >{{ item.key }}</span
- >
- </tooltip-on-truncate>
- <gl-button
- v-gl-tooltip
- category="tertiary"
- icon="copy-to-clipboard"
- :title="__('Copy key')"
- :data-clipboard-text="item.key"
- :aria-label="__('Copy to clipboard')"
- />
- </div>
- </template>
- <template #cell(value)="{ item }">
- <div class="gl-display-flex gl-align-items-center">
- <span v-if="valuesHidden">*********************</span>
- <span
- v-else
- :id="`ci-variable-value-${item.id}`"
- class="gl-display-inline-block gl-max-w-full gl-text-truncate"
- >{{ item.value }}</span
- >
- <gl-button
- v-gl-tooltip
- category="tertiary"
- icon="copy-to-clipboard"
- :title="__('Copy value')"
- :data-clipboard-text="item.value"
- :aria-label="__('Copy to clipboard')"
- />
- </div>
- </template>
- <template #cell(protected)="{ item }">
- <gl-icon v-if="item.protected" :size="$options.iconSize" :name="$options.trueIcon" />
- <gl-icon v-else :size="$options.iconSize" :name="$options.falseIcon" />
- </template>
- <template #cell(masked)="{ item }">
- <gl-icon v-if="item.masked" :size="$options.iconSize" :name="$options.trueIcon" />
- <gl-icon v-else :size="$options.iconSize" :name="$options.falseIcon" />
- </template>
- <template #cell(environment_scope)="{ item }">
- <div class="gl-display-flex">
- <span
- :id="`ci-variable-env-${item.id}`"
- class="gl-display-inline-block gl-max-w-full gl-text-truncate"
- >{{ item.environment_scope }}</span
- >
- <ci-variable-popover
- :target="`ci-variable-env-${item.id}`"
- :value="item.environment_scope"
- :tooltip-text="__('Copy environment')"
- />
- </div>
- </template>
- <template #cell(actions)="{ item }">
- <gl-button
- v-gl-modal-directive="$options.modalId"
- icon="pencil"
- :aria-label="__('Edit')"
- data-qa-selector="edit_ci_variable_button"
- @click="editVariable(item)"
- />
- </template>
- <template #empty>
- <p class="gl-text-center gl-py-6 gl-text-black-normal gl-mb-0">
- {{ __('There are no variables yet.') }}
- </p>
- </template>
- </gl-table>
- <div class="ci-variable-actions gl-display-flex gl-mt-5">
- <gl-button
- v-gl-modal-directive="$options.modalId"
- class="gl-mr-3"
- data-qa-selector="add_ci_variable_button"
- variant="confirm"
- category="primary"
- >{{ __('Add variable') }}</gl-button
- >
- <gl-button
- v-if="!isTableEmpty"
- data-qa-selector="reveal_ci_variable_value_button"
- @click="toggleValues(!valuesHidden)"
- >{{ valuesButtonText }}</gl-button
- >
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql
index eba4b0c32f8..9208c34f154 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql
@@ -1,7 +1,7 @@
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
mutation addAdminVariable($variable: CiVariable!, $endpoint: String!) {
- addAdminVariable(variable: $variable, endpoint: $endpoint) @client {
+ ciVariableMutation: addAdminVariable(variable: $variable, endpoint: $endpoint) @client {
ciVariables {
nodes {
...BaseCiVariable
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql
index 96eb8c794bc..a79b98f5e95 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql
@@ -1,7 +1,7 @@
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
mutation deleteAdminVariable($variable: CiVariable!, $endpoint: String!) {
- deleteAdminVariable(variable: $variable, endpoint: $endpoint) @client {
+ ciVariableMutation: deleteAdminVariable(variable: $variable, endpoint: $endpoint) @client {
ciVariables {
nodes {
...BaseCiVariable
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql
index c0388507bb8..ddea753bf90 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql
@@ -1,7 +1,7 @@
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
mutation updateAdminVariable($variable: CiVariable!, $endpoint: String!) {
- updateAdminVariable(variable: $variable, endpoint: $endpoint) @client {
+ ciVariableMutation: updateAdminVariable(variable: $variable, endpoint: $endpoint) @client {
ciVariables {
nodes {
...BaseCiVariable
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql
index f8e4dc55fa4..c44ee2ecc1d 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql
@@ -1,16 +1,11 @@
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
-mutation addGroupVariable(
- $variable: CiVariable!
- $endpoint: String!
- $fullPath: ID!
- $groupId: ID!
-) {
- addGroupVariable(
+mutation addGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) {
+ ciVariableMutation: addGroupVariable(
variable: $variable
endpoint: $endpoint
fullPath: $fullPath
- groupId: $groupId
+ id: $id
) @client {
group {
id
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql
index 310e4a6e551..53e9b411dd2 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql
@@ -1,16 +1,11 @@
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
-mutation deleteGroupVariable(
- $variable: CiVariable!
- $endpoint: String!
- $fullPath: ID!
- $groupId: ID!
-) {
- deleteGroupVariable(
+mutation deleteGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) {
+ ciVariableMutation: deleteGroupVariable(
variable: $variable
endpoint: $endpoint
fullPath: $fullPath
- groupId: $groupId
+ id: $id
) @client {
group {
id
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql
index 5291942eb87..2dddca14bd8 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql
@@ -1,16 +1,11 @@
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
-mutation updateGroupVariable(
- $variable: CiVariable!
- $endpoint: String!
- $fullPath: ID!
- $groupId: ID!
-) {
- updateGroupVariable(
+mutation updateGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) {
+ ciVariableMutation: updateGroupVariable(
variable: $variable
endpoint: $endpoint
fullPath: $fullPath
- groupId: $groupId
+ id: $id
) @client {
group {
id
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql
index ab3a46da854..39504770e33 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql
@@ -1,16 +1,11 @@
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
-mutation addProjectVariable(
- $variable: CiVariable!
- $endpoint: String!
- $fullPath: ID!
- $projectId: ID!
-) {
- addProjectVariable(
+mutation addProjectVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) {
+ ciVariableMutation: addProjectVariable(
variable: $variable
endpoint: $endpoint
fullPath: $fullPath
- projectId: $projectId
+ id: $id
) @client {
project {
id
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql
index e83dc9a5e5e..f55c255e332 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql
@@ -4,13 +4,13 @@ mutation deleteProjectVariable(
$variable: CiVariable!
$endpoint: String!
$fullPath: ID!
- $projectId: ID!
+ $id: ID!
) {
- deleteProjectVariable(
+ ciVariableMutation: deleteProjectVariable(
variable: $variable
endpoint: $endpoint
fullPath: $fullPath
- projectId: $projectId
+ id: $id
) @client {
project {
id
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql
index 4788911431b..fc589e8a939 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql
@@ -4,13 +4,13 @@ mutation updateProjectVariable(
$variable: CiVariable!
$endpoint: String!
$fullPath: ID!
- $projectId: ID!
+ $id: ID!
) {
- updateProjectVariable(
+ ciVariableMutation: updateProjectVariable(
variable: $variable
endpoint: $endpoint
fullPath: $fullPath
- projectId: $projectId
+ id: $id
) @client {
project {
id
diff --git a/app/assets/javascripts/ci_variable_list/graphql/settings.js b/app/assets/javascripts/ci_variable_list/graphql/settings.js
index ecdc4f220bd..02f6c226b0f 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/settings.js
+++ b/app/assets/javascripts/ci_variable_list/graphql/settings.js
@@ -36,12 +36,12 @@ const mapVariableTypes = (variables = [], kind) => {
});
};
-const prepareProjectGraphQLResponse = ({ data, projectId, errors = [] }) => {
+const prepareProjectGraphQLResponse = ({ data, id, errors = [] }) => {
return {
errors,
project: {
__typename: GRAPHQL_PROJECT_TYPE,
- id: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, projectId),
+ id: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, id),
ciVariables: {
__typename: `Ci${GRAPHQL_PROJECT_TYPE}VariableConnection`,
pageInfo: {
@@ -57,12 +57,12 @@ const prepareProjectGraphQLResponse = ({ data, projectId, errors = [] }) => {
};
};
-const prepareGroupGraphQLResponse = ({ data, groupId, errors = [] }) => {
+const prepareGroupGraphQLResponse = ({ data, id, errors = [] }) => {
return {
errors,
group: {
__typename: GRAPHQL_GROUP_TYPE,
- id: convertToGraphQLId(GRAPHQL_GROUP_TYPE, groupId),
+ id: convertToGraphQLId(GRAPHQL_GROUP_TYPE, id),
ciVariables: {
__typename: `Ci${GRAPHQL_GROUP_TYPE}VariableConnection`,
pageInfo: {
@@ -95,20 +95,13 @@ const prepareAdminGraphQLResponse = ({ data, errors = [] }) => {
};
};
-async function callProjectEndpoint({
- endpoint,
- fullPath,
- variable,
- projectId,
- cache,
- destroy = false,
-}) {
+async function callProjectEndpoint({ endpoint, fullPath, variable, id, cache, destroy = false }) {
try {
const { data } = await axios.patch(endpoint, {
variables_attributes: [prepareVariableForApi({ variable, destroy })],
});
- const graphqlData = prepareProjectGraphQLResponse({ data, projectId });
+ const graphqlData = prepareProjectGraphQLResponse({ data, id });
cache.writeQuery({
query: getProjectVariables,
@@ -122,26 +115,19 @@ async function callProjectEndpoint({
} catch (e) {
return prepareProjectGraphQLResponse({
data: cache.readQuery({ query: getProjectVariables, variables: { fullPath } }),
- projectId,
+ id,
errors: [...e.response.data],
});
}
}
-const callGroupEndpoint = async ({
- endpoint,
- fullPath,
- variable,
- groupId,
- cache,
- destroy = false,
-}) => {
+const callGroupEndpoint = async ({ endpoint, fullPath, variable, id, cache, destroy = false }) => {
try {
const { data } = await axios.patch(endpoint, {
variables_attributes: [prepareVariableForApi({ variable, destroy })],
});
- const graphqlData = prepareGroupGraphQLResponse({ data, groupId });
+ const graphqlData = prepareGroupGraphQLResponse({ data, id });
cache.writeQuery({
query: getGroupVariables,
@@ -152,7 +138,7 @@ const callGroupEndpoint = async ({
} catch (e) {
return prepareGroupGraphQLResponse({
data: cache.readQuery({ query: getGroupVariables, variables: { fullPath } }),
- groupId,
+ id,
errors: [...e.response.data],
});
}
@@ -182,23 +168,23 @@ const callAdminEndpoint = async ({ endpoint, variable, cache, destroy = false })
export const resolvers = {
Mutation: {
- addProjectVariable: async (_, { endpoint, fullPath, variable, projectId }, { cache }) => {
- return callProjectEndpoint({ endpoint, fullPath, variable, projectId, cache });
+ addProjectVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => {
+ return callProjectEndpoint({ endpoint, fullPath, variable, id, cache });
},
- updateProjectVariable: async (_, { endpoint, fullPath, variable, projectId }, { cache }) => {
- return callProjectEndpoint({ endpoint, fullPath, variable, projectId, cache });
+ updateProjectVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => {
+ return callProjectEndpoint({ endpoint, fullPath, variable, id, cache });
},
- deleteProjectVariable: async (_, { endpoint, fullPath, variable, projectId }, { cache }) => {
- return callProjectEndpoint({ endpoint, fullPath, variable, projectId, cache, destroy: true });
+ deleteProjectVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => {
+ return callProjectEndpoint({ endpoint, fullPath, variable, id, cache, destroy: true });
},
- addGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => {
- return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache });
+ addGroupVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => {
+ return callGroupEndpoint({ endpoint, fullPath, variable, id, cache });
},
- updateGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => {
- return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache });
+ updateGroupVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => {
+ return callGroupEndpoint({ endpoint, fullPath, variable, id, cache });
},
- deleteGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => {
- return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache, destroy: true });
+ deleteGroupVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => {
+ return callGroupEndpoint({ endpoint, fullPath, variable, id, cache, destroy: true });
},
addAdminVariable: async (_, { endpoint, variable }, { cache }) => {
return callAdminEndpoint({ endpoint, variable, cache });
@@ -238,7 +224,7 @@ export const cacheConfig = {
Project: {
fields: {
ciVariables: {
- keyArgs: ['fullPath', 'endpoint', 'projectId'],
+ keyArgs: ['fullPath', 'endpoint', 'id'],
merge: mergeVariables,
},
},
diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js
index 1b69da9e086..174a59aba42 100644
--- a/app/assets/javascripts/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci_variable_list/index.js
@@ -5,9 +5,7 @@ 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 LegacyCiVariableSettings from './components/legacy_ci_variable_settings.vue';
import { cacheConfig, resolvers } from './graphql/settings';
-import createStore from './store';
const mountCiVariableListApp = (containerEl) => {
const {
@@ -76,62 +74,10 @@ const mountCiVariableListApp = (containerEl) => {
});
};
-const mountLegacyCiVariableListApp = (containerEl) => {
- const {
- endpoint,
- projectId,
- isGroup,
- isProject,
- maskableRegex,
- protectedByDefault,
- awsLogoSvgPath,
- awsTipDeployLink,
- awsTipCommandsLink,
- awsTipLearnLink,
- containsVariableReferenceLink,
- protectedEnvironmentVariablesLink,
- maskedEnvironmentVariablesLink,
- environmentScopeLink,
- } = containerEl.dataset;
-
- const parsedIsProject = parseBoolean(isProject);
- const parsedIsGroup = parseBoolean(isGroup);
- const isProtectedByDefault = parseBoolean(protectedByDefault);
-
- const store = createStore({
- endpoint,
- projectId,
- isGroup: parsedIsGroup,
- isProject: parsedIsProject,
- maskableRegex,
- isProtectedByDefault,
- awsLogoSvgPath,
- awsTipDeployLink,
- awsTipCommandsLink,
- awsTipLearnLink,
- containsVariableReferenceLink,
- protectedEnvironmentVariablesLink,
- maskedEnvironmentVariablesLink,
- environmentScopeLink,
- });
-
- return new Vue({
- el: containerEl,
- store,
- render(createElement) {
- return createElement(LegacyCiVariableSettings);
- },
- });
-};
-
-export default (containerId = 'js-ci-project-variables') => {
+export default (containerId = 'js-ci-variables') => {
const el = document.getElementById(containerId);
- if (el) {
- if (gon.features?.ciVariableSettingsGraphql) {
- mountCiVariableListApp(el);
- } else {
- mountLegacyCiVariableListApp(el);
- }
- }
+ if (!el) return;
+
+ mountCiVariableListApp(el);
};
diff --git a/app/assets/javascripts/ci_variable_list/store/actions.js b/app/assets/javascripts/ci_variable_list/store/actions.js
deleted file mode 100644
index ac31e845b0d..00000000000
--- a/app/assets/javascripts/ci_variable_list/store/actions.js
+++ /dev/null
@@ -1,208 +0,0 @@
-import Api from '~/api';
-import { createAlert } from '~/flash';
-import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
-import * as types from './mutation_types';
-import { prepareDataForApi, prepareDataForDisplay, prepareEnvironments } from './utils';
-
-export const toggleValues = ({ commit }, valueState) => {
- commit(types.TOGGLE_VALUES, valueState);
-};
-
-export const clearModal = ({ commit }) => {
- commit(types.CLEAR_MODAL);
-};
-
-export const resetEditing = ({ commit, dispatch }) => {
- // fetch variables again if modal is being edited and then hidden
- // without saving changes, to cover use case of reactivity in the table
- dispatch('fetchVariables');
- commit(types.RESET_EDITING);
-};
-
-export const setVariableProtected = ({ commit }) => {
- commit(types.SET_VARIABLE_PROTECTED);
-};
-
-export const requestAddVariable = ({ commit }) => {
- commit(types.REQUEST_ADD_VARIABLE);
-};
-
-export const receiveAddVariableSuccess = ({ commit }) => {
- commit(types.RECEIVE_ADD_VARIABLE_SUCCESS);
-};
-
-export const receiveAddVariableError = ({ commit }, error) => {
- commit(types.RECEIVE_ADD_VARIABLE_ERROR, error);
-};
-
-export const addVariable = ({ state, dispatch }) => {
- dispatch('requestAddVariable');
-
- return axios
- .patch(state.endpoint, {
- variables_attributes: [prepareDataForApi(state.variable)],
- })
- .then(() => {
- dispatch('receiveAddVariableSuccess');
- dispatch('fetchVariables');
- })
- .catch((error) => {
- createAlert({
- message: error.response.data[0],
- });
- dispatch('receiveAddVariableError', error);
- });
-};
-
-export const requestUpdateVariable = ({ commit }) => {
- commit(types.REQUEST_UPDATE_VARIABLE);
-};
-
-export const receiveUpdateVariableSuccess = ({ commit }) => {
- commit(types.RECEIVE_UPDATE_VARIABLE_SUCCESS);
-};
-
-export const receiveUpdateVariableError = ({ commit }, error) => {
- commit(types.RECEIVE_UPDATE_VARIABLE_ERROR, error);
-};
-
-export const updateVariable = ({ state, dispatch }) => {
- dispatch('requestUpdateVariable');
-
- const updatedVariable = prepareDataForApi(state.variable);
- updatedVariable.secrect_value = updateVariable.value;
-
- return axios
- .patch(state.endpoint, { variables_attributes: [updatedVariable] })
- .then(() => {
- dispatch('receiveUpdateVariableSuccess');
- dispatch('fetchVariables');
- })
- .catch((error) => {
- createAlert({
- message: error.response.data[0],
- });
- dispatch('receiveUpdateVariableError', error);
- });
-};
-
-export const editVariable = ({ commit }, variable) => {
- const variableToEdit = variable;
- variableToEdit.secret_value = variableToEdit.value;
- commit(types.VARIABLE_BEING_EDITED, variableToEdit);
-};
-
-export const requestVariables = ({ commit }) => {
- commit(types.REQUEST_VARIABLES);
-};
-export const receiveVariablesSuccess = ({ commit }, variables) => {
- commit(types.RECEIVE_VARIABLES_SUCCESS, variables);
-};
-
-export const fetchVariables = ({ dispatch, state }) => {
- dispatch('requestVariables');
-
- return axios
- .get(state.endpoint)
- .then(({ data }) => {
- dispatch('receiveVariablesSuccess', prepareDataForDisplay(data.variables));
- })
- .catch(() => {
- createAlert({
- message: __('There was an error fetching the variables.'),
- });
- });
-};
-
-export const requestDeleteVariable = ({ commit }) => {
- commit(types.REQUEST_DELETE_VARIABLE);
-};
-
-export const receiveDeleteVariableSuccess = ({ commit }) => {
- commit(types.RECEIVE_DELETE_VARIABLE_SUCCESS);
-};
-
-export const receiveDeleteVariableError = ({ commit }, error) => {
- commit(types.RECEIVE_DELETE_VARIABLE_ERROR, error);
-};
-
-export const deleteVariable = ({ dispatch, state }) => {
- dispatch('requestDeleteVariable');
-
- const destroy = true;
-
- return axios
- .patch(state.endpoint, { variables_attributes: [prepareDataForApi(state.variable, destroy)] })
- .then(() => {
- dispatch('receiveDeleteVariableSuccess');
- dispatch('fetchVariables');
- })
- .catch((error) => {
- createAlert({
- message: error.response.data[0],
- });
- dispatch('receiveDeleteVariableError', error);
- });
-};
-
-export const requestEnvironments = ({ commit }) => {
- commit(types.REQUEST_ENVIRONMENTS);
-};
-
-export const receiveEnvironmentsSuccess = ({ commit }, environments) => {
- commit(types.RECEIVE_ENVIRONMENTS_SUCCESS, environments);
-};
-
-export const fetchEnvironments = ({ dispatch, state }) => {
- dispatch('requestEnvironments');
-
- return Api.environments(state.projectId)
- .then((res) => {
- dispatch('receiveEnvironmentsSuccess', prepareEnvironments(res.data));
- })
- .catch(() => {
- createAlert({
- message: __('There was an error fetching the environments information.'),
- });
- });
-};
-
-export const setEnvironmentScope = ({ commit, dispatch }, environment) => {
- commit(types.SET_ENVIRONMENT_SCOPE, environment);
- dispatch('setSelectedEnvironment', environment);
-};
-
-export const addWildCardScope = ({ commit, dispatch }, environment) => {
- commit(types.ADD_WILD_CARD_SCOPE, environment);
- commit(types.SET_ENVIRONMENT_SCOPE, environment);
- dispatch('setSelectedEnvironment', environment);
-};
-
-export const resetSelectedEnvironment = ({ commit }) => {
- commit(types.RESET_SELECTED_ENVIRONMENT);
-};
-
-export const setSelectedEnvironment = ({ commit }, environment) => {
- commit(types.SET_SELECTED_ENVIRONMENT, environment);
-};
-
-export const updateVariableKey = ({ commit }, { key }) => {
- commit(types.UPDATE_VARIABLE_KEY, key);
-};
-
-export const updateVariableValue = ({ commit }, { secret_value }) => {
- commit(types.UPDATE_VARIABLE_VALUE, secret_value);
-};
-
-export const updateVariableType = ({ commit }, { variable_type }) => {
- commit(types.UPDATE_VARIABLE_TYPE, variable_type);
-};
-
-export const updateVariableProtected = ({ commit }, { protected_variable }) => {
- commit(types.UPDATE_VARIABLE_PROTECTED, protected_variable);
-};
-
-export const updateVariableMasked = ({ commit }, { masked }) => {
- commit(types.UPDATE_VARIABLE_MASKED, masked);
-};
diff --git a/app/assets/javascripts/ci_variable_list/store/getters.js b/app/assets/javascripts/ci_variable_list/store/getters.js
deleted file mode 100644
index 6570f455541..00000000000
--- a/app/assets/javascripts/ci_variable_list/store/getters.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import { uniq } from 'lodash';
-
-export const joinedEnvironments = (state) => {
- const scopesFromVariables = (state.variables || []).map((variable) => variable.environment_scope);
- return uniq(state.environments.concat(scopesFromVariables)).sort();
-};
diff --git a/app/assets/javascripts/ci_variable_list/store/index.js b/app/assets/javascripts/ci_variable_list/store/index.js
deleted file mode 100644
index 83802f6a36f..00000000000
--- a/app/assets/javascripts/ci_variable_list/store/index.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import * as actions from './actions';
-import * as getters from './getters';
-import mutations from './mutations';
-import state from './state';
-
-Vue.use(Vuex);
-
-export default (initialState = {}) =>
- new Vuex.Store({
- actions,
- mutations,
- getters,
- state: {
- ...state(),
- ...initialState,
- },
- });
diff --git a/app/assets/javascripts/ci_variable_list/store/mutation_types.js b/app/assets/javascripts/ci_variable_list/store/mutation_types.js
deleted file mode 100644
index 5db8f610192..00000000000
--- a/app/assets/javascripts/ci_variable_list/store/mutation_types.js
+++ /dev/null
@@ -1,33 +0,0 @@
-export const TOGGLE_VALUES = 'TOGGLE_VALUES';
-export const VARIABLE_BEING_EDITED = 'VARIABLE_BEING_EDITED';
-export const RESET_EDITING = 'RESET_EDITING';
-export const CLEAR_MODAL = 'CLEAR_MODAL';
-export const SET_VARIABLE_PROTECTED = 'SET_VARIABLE_PROTECTED';
-
-export const REQUEST_VARIABLES = 'REQUEST_VARIABLES';
-export const RECEIVE_VARIABLES_SUCCESS = 'RECEIVE_VARIABLES_SUCCESS';
-
-export const REQUEST_DELETE_VARIABLE = 'REQUEST_DELETE_VARIABLE';
-export const RECEIVE_DELETE_VARIABLE_SUCCESS = 'RECEIVE_DELETE_VARIABLE_SUCCESS';
-export const RECEIVE_DELETE_VARIABLE_ERROR = 'RECEIVE_DELETE_VARIABLE_ERROR';
-
-export const REQUEST_ADD_VARIABLE = 'REQUEST_ADD_VARIABLE';
-export const RECEIVE_ADD_VARIABLE_SUCCESS = 'RECEIVE_ADD_VARIABLE_SUCCESS';
-export const RECEIVE_ADD_VARIABLE_ERROR = 'RECEIVE_ADD_VARIABLE_ERROR';
-
-export const REQUEST_UPDATE_VARIABLE = 'REQUEST_UPDATE_VARIABLE';
-export const RECEIVE_UPDATE_VARIABLE_SUCCESS = 'RECEIVE_UPDATE_VARIABLE_SUCCESS';
-export const RECEIVE_UPDATE_VARIABLE_ERROR = 'RECEIVE_UPDATE_VARIABLE_ERROR';
-
-export const REQUEST_ENVIRONMENTS = 'REQUEST_ENVIRONMENTS';
-export const RECEIVE_ENVIRONMENTS_SUCCESS = 'RECEIVE_ENVIRONMENTS_SUCCESS';
-export const SET_ENVIRONMENT_SCOPE = 'SET_ENVIRONMENT_SCOPE';
-export const ADD_WILD_CARD_SCOPE = 'ADD_WILD_CARD_SCOPE';
-export const RESET_SELECTED_ENVIRONMENT = 'RESET_SELECTED_ENVIRONMENT';
-export const SET_SELECTED_ENVIRONMENT = 'SET_SELECTED_ENVIRONMENT';
-
-export const UPDATE_VARIABLE_KEY = 'UPDATE_VARIABLE_KEY';
-export const UPDATE_VARIABLE_VALUE = 'UPDATE_VARIABLE_VALUE';
-export const UPDATE_VARIABLE_TYPE = 'UPDATE_VARIABLE_TYPE';
-export const UPDATE_VARIABLE_PROTECTED = 'UPDATE_VARIABLE_PROTECTED';
-export const UPDATE_VARIABLE_MASKED = 'UPDATE_VARIABLE_MASKED';
diff --git a/app/assets/javascripts/ci_variable_list/store/mutations.js b/app/assets/javascripts/ci_variable_list/store/mutations.js
deleted file mode 100644
index 0e7c61cecb8..00000000000
--- a/app/assets/javascripts/ci_variable_list/store/mutations.js
+++ /dev/null
@@ -1,128 +0,0 @@
-import { displayText } from '../constants';
-import * as types from './mutation_types';
-
-export default {
- [types.REQUEST_VARIABLES](state) {
- state.isLoading = true;
- },
-
- [types.RECEIVE_VARIABLES_SUCCESS](state, variables) {
- state.isLoading = false;
- state.variables = variables;
- },
-
- [types.REQUEST_DELETE_VARIABLE](state) {
- state.isDeleting = true;
- },
-
- [types.RECEIVE_DELETE_VARIABLE_SUCCESS](state) {
- state.isDeleting = false;
- },
-
- [types.RECEIVE_DELETE_VARIABLE_ERROR](state, error) {
- state.isDeleting = false;
- state.error = error;
- },
-
- [types.REQUEST_ADD_VARIABLE](state) {
- state.isLoading = true;
- },
-
- [types.RECEIVE_ADD_VARIABLE_SUCCESS](state) {
- state.isLoading = false;
- },
-
- [types.RECEIVE_ADD_VARIABLE_ERROR](state, error) {
- state.isLoading = false;
- state.error = error;
- },
-
- [types.REQUEST_UPDATE_VARIABLE](state) {
- state.isLoading = true;
- },
-
- [types.RECEIVE_UPDATE_VARIABLE_SUCCESS](state) {
- state.isLoading = false;
- },
-
- [types.RECEIVE_UPDATE_VARIABLE_ERROR](state, error) {
- state.isLoading = false;
- state.error = error;
- },
-
- [types.TOGGLE_VALUES](state, valueState) {
- state.valuesHidden = valueState;
- },
-
- [types.REQUEST_ENVIRONMENTS](state) {
- state.isLoading = true;
- },
-
- [types.RECEIVE_ENVIRONMENTS_SUCCESS](state, environments) {
- state.isLoading = false;
- state.environments = environments;
- state.environments.unshift(displayText.allEnvironmentsText);
- },
-
- [types.VARIABLE_BEING_EDITED](state, variable) {
- state.variableBeingEdited = true;
- state.variable = variable;
- },
-
- [types.CLEAR_MODAL](state) {
- state.variable = {
- variable_type: displayText.variableText,
- key: '',
- secret_value: '',
- protected_variable: false,
- masked: false,
- environment_scope: displayText.allEnvironmentsText,
- };
- },
-
- [types.RESET_EDITING](state) {
- state.variableBeingEdited = false;
- state.showInputValue = false;
- },
-
- [types.SET_ENVIRONMENT_SCOPE](state, environment) {
- state.variable.environment_scope = environment;
- },
-
- [types.ADD_WILD_CARD_SCOPE](state, environment) {
- state.environments.push(environment);
- state.environments.sort();
- },
-
- [types.RESET_SELECTED_ENVIRONMENT](state) {
- state.selectedEnvironment = '';
- },
-
- [types.SET_SELECTED_ENVIRONMENT](state, environment) {
- state.selectedEnvironment = environment;
- },
-
- [types.SET_VARIABLE_PROTECTED](state) {
- state.variable.protected_variable = true;
- },
-
- [types.UPDATE_VARIABLE_KEY](state, key) {
- state.variable.key = key;
- },
-
- [types.UPDATE_VARIABLE_VALUE](state, value) {
- state.variable.secret_value = value;
- },
-
- [types.UPDATE_VARIABLE_TYPE](state, type) {
- state.variable.variable_type = type;
- },
-
- [types.UPDATE_VARIABLE_PROTECTED](state, bool) {
- state.variable.protected_variable = bool;
- },
-
- [types.UPDATE_VARIABLE_MASKED](state, bool) {
- state.variable.masked = bool;
- },
-};
diff --git a/app/assets/javascripts/ci_variable_list/store/state.js b/app/assets/javascripts/ci_variable_list/store/state.js
deleted file mode 100644
index 96b27792664..00000000000
--- a/app/assets/javascripts/ci_variable_list/store/state.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { displayText } from '../constants';
-
-export default () => ({
- endpoint: null,
- projectId: null,
- isGroup: null,
- maskableRegex: null,
- isProtectedByDefault: null,
- isLoading: false,
- isDeleting: false,
- variable: {
- variable_type: displayText.variableText,
- key: '',
- secret_value: '',
- protected_variable: false,
- masked: false,
- environment_scope: displayText.allEnvironmentsText,
- },
- variables: null,
- valuesHidden: true,
- error: null,
- environments: [],
- typeOptions: [displayText.variableText, displayText.fileText],
- variableBeingEdited: false,
- selectedEnvironment: '',
-});
diff --git a/app/assets/javascripts/ci_variable_list/store/utils.js b/app/assets/javascripts/ci_variable_list/store/utils.js
deleted file mode 100644
index f46a671ae7b..00000000000
--- a/app/assets/javascripts/ci_variable_list/store/utils.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import { cloneDeep } from 'lodash';
-import { displayText, types, allEnvironments } from '../constants';
-
-const variableTypeHandler = (type) =>
- type === displayText.variableText ? types.variableType : types.fileType;
-
-export const prepareDataForDisplay = (variables) => {
- const variablesToDisplay = [];
- variables.forEach((variable) => {
- const variableCopy = variable;
- if (variableCopy.variable_type === types.variableType) {
- variableCopy.variable_type = displayText.variableText;
- } else {
- variableCopy.variable_type = displayText.fileText;
- }
- variableCopy.secret_value = variableCopy.value;
-
- if (variableCopy.environment_scope === allEnvironments.type) {
- variableCopy.environment_scope = displayText.allEnvironmentsText;
- }
- variableCopy.protected_variable = variableCopy.protected;
- variablesToDisplay.push(variableCopy);
- });
- return variablesToDisplay;
-};
-
-export const prepareDataForApi = (variable, destroy = false) => {
- const variableCopy = cloneDeep(variable);
- variableCopy.protected = variableCopy.protected_variable.toString();
- delete variableCopy.protected_variable;
- variableCopy.masked = variableCopy.masked.toString();
- variableCopy.variable_type = variableTypeHandler(variableCopy.variable_type);
- if (variableCopy.environment_scope === displayText.allEnvironmentsText) {
- variableCopy.environment_scope = allEnvironments.type;
- }
-
- if (destroy) {
- // eslint-disable-next-line
- variableCopy._destroy = destroy;
- }
-
- return variableCopy;
-};
-
-export const prepareEnvironments = (environments) => environments.map((e) => e.name);
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 6f2c353a67b..7a028858d10 100644
--- a/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
+++ b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
@@ -92,6 +92,9 @@ export default {
disableModalSubmit() {
return this.deleteConfirmText !== this.agent.name;
},
+ containerTabIndex() {
+ return this.canAdminCluster ? -1 : 0;
+ },
},
methods: {
async deleteAgent() {
@@ -156,8 +159,8 @@ export default {
<div>
<div
v-gl-tooltip="deleteButtonTooltip"
- class="gl-display-inline-block"
- tabindex="-1"
+ :tabindex="containerTabIndex"
+ class="cluster-button-container gl-rounded-base gl-display-inline-block"
data-testid="delete-agent-button-tooltip"
>
<gl-button
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
index 327b0967229..354db88f11c 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
@@ -108,6 +108,16 @@ export default {
@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"
diff --git a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue
index 987b7044272..001b34a00fa 100644
--- a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue
@@ -90,6 +90,9 @@ export default {
items() {
this.selectedIndex = 0;
},
+ selectedIndex() {
+ this.scrollIntoView();
+ },
},
methods: {
@@ -182,6 +185,10 @@ export default {
this.selectItem(this.selectedIndex);
},
+ scrollIntoView() {
+ this.$refs.dropdownItems[this.selectedIndex].$el.scrollIntoView({ block: 'nearest' });
+ },
+
selectItem(index) {
const item = this.items[index];
@@ -209,6 +216,7 @@ export default {
<div class="gl-new-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)"
diff --git a/app/assets/javascripts/content_editor/components/toolbar_button.vue b/app/assets/javascripts/content_editor/components/toolbar_button.vue
index c16dc34e36f..cef026c5bc6 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_button.vue
@@ -32,7 +32,7 @@ export default {
editorCommandParams: {
type: Object,
required: false,
- default: null,
+ default: undefined,
},
variant: {
type: String,
diff --git a/app/assets/javascripts/content_editor/content_editor.stories.js b/app/assets/javascripts/content_editor/content_editor.stories.js
index 2d4226ccd33..9e1a4bfe361 100644
--- a/app/assets/javascripts/content_editor/content_editor.stories.js
+++ b/app/assets/javascripts/content_editor/content_editor.stories.js
@@ -11,7 +11,6 @@ const Template = (_, { argTypes }) => ({
template: '<content-editor v-bind="$props" @initialized="loadContent" />',
methods: {
loadContent(contentEditor) {
- // eslint-disable-next-line @gitlab/require-i18n-strings
contentEditor.setSerializedContent('Hello content editor');
},
},
diff --git a/app/assets/javascripts/content_editor/extensions/highlight.js b/app/assets/javascripts/content_editor/extensions/highlight.js
new file mode 100644
index 00000000000..b84388d6285
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/highlight.js
@@ -0,0 +1,19 @@
+import { markInputRule } from '@tiptap/core';
+import { Highlight } from '@tiptap/extension-highlight';
+import { markInputRegex, extractMarkAttributesFromMatch } from '../services/mark_utils';
+
+export default Highlight.extend({
+ addInputRules() {
+ return [
+ markInputRule({
+ find: markInputRegex('mark'),
+ type: this.type,
+ getAttributes: extractMarkAttributesFromMatch,
+ }),
+ ];
+ },
+
+ addPasteRules() {
+ return [];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/html_marks.js b/app/assets/javascripts/content_editor/extensions/html_marks.js
index 9579f3b06f6..79fc0eea2c7 100644
--- a/app/assets/javascripts/content_editor/extensions/html_marks.js
+++ b/app/assets/javascripts/content_editor/extensions/html_marks.js
@@ -8,7 +8,6 @@ const marks = [
'bdo',
'cite',
'dfn',
- 'mark',
'small',
'span',
'time',
diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js
index 8976b9cafee..a9628c78add 100644
--- a/app/assets/javascripts/content_editor/extensions/suggestions.js
+++ b/app/assets/javascripts/content_editor/extensions/suggestions.js
@@ -17,7 +17,7 @@ function createSuggestionPlugin({
char,
dataSource,
search,
- limit = Infinity,
+ limit = 15,
nodeType,
nodeProps = {},
}) {
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 0d78390e769..ba9ce705c62 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -29,6 +29,7 @@ import Gapcursor from '../extensions/gapcursor';
import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
import History from '../extensions/history';
+import Highlight from '../extensions/highlight';
import HorizontalRule from '../extensions/horizontal_rule';
import HTMLMarks from '../extensions/html_marks';
import HTMLNodes from '../extensions/html_nodes';
@@ -118,6 +119,7 @@ export const createContentEditor = ({
HardBreak,
Heading,
History,
+ Highlight,
HorizontalRule,
...HTMLMarks,
...HTMLNodes,
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index c990f6cf0b3..958c27c281a 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -22,6 +22,7 @@ import Frontmatter from '../extensions/frontmatter';
import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
import HorizontalRule from '../extensions/horizontal_rule';
+import Highlight from '../extensions/highlight';
import HTMLMarks from '../extensions/html_marks';
import HTMLNodes from '../extensions/html_nodes';
import Image from '../extensions/image';
@@ -78,6 +79,7 @@ const defaultSerializerConfig = {
[Code.name]: code,
[Subscript.name]: { open: '<sub>', close: '</sub>', mixable: true },
[Superscript.name]: { open: '<sup>', close: '</sup>', mixable: true },
+ [Highlight.name]: { open: '<mark>', close: '</mark>', mixable: true },
[InlineDiff.name]: {
mixable: true,
open(_, mark) {
diff --git a/app/assets/javascripts/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/cycle_analytics/components/filter_bar.vue
index 016fea354fe..0ad325a8523 100644
--- a/app/assets/javascripts/cycle_analytics/components/filter_bar.vue
+++ b/app/assets/javascripts/cycle_analytics/components/filter_bar.vue
@@ -1,9 +1,12 @@
<script>
import { mapActions, mapState } from 'vuex';
-import { __ } from '~/locale';
import {
OPERATOR_IS_ONLY,
DEFAULT_NONE_ANY,
+ TOKEN_TITLE_ASSIGNEE,
+ TOKEN_TITLE_AUTHOR,
+ TOKEN_TITLE_LABEL,
+ TOKEN_TITLE_MILESTONE,
} from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import {
@@ -43,7 +46,7 @@ export default {
return [
{
icon: 'clock',
- title: __('Milestone'),
+ title: TOKEN_TITLE_MILESTONE,
type: 'milestone',
token: MilestoneToken,
initialMilestones: this.milestonesData,
@@ -54,7 +57,7 @@ export default {
},
{
icon: 'labels',
- title: __('Label'),
+ title: TOKEN_TITLE_LABEL,
type: 'labels',
token: LabelToken,
defaultLabels: DEFAULT_NONE_ANY,
@@ -66,7 +69,7 @@ export default {
},
{
icon: 'pencil',
- title: __('Author'),
+ title: TOKEN_TITLE_AUTHOR,
type: 'author',
token: AuthorToken,
initialAuthors: this.authorsData,
@@ -76,7 +79,7 @@ export default {
},
{
icon: 'user',
- title: __('Assignees'),
+ title: TOKEN_TITLE_ASSIGNEE,
type: 'assignees',
token: AuthorToken,
initialAuthors: this.assigneesData,
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 639dd21bd7b..81d74c64124 100644
--- a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue
+++ b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue
@@ -109,10 +109,11 @@ export default {
writePackageRegistryHelp: s__(
'DeployTokens|Allows read and write access to the package registry.',
),
+ createTokenFailedAlert: s__('DeployTokens|Failed to create a new deployment token'),
},
computed: {
formattedExpiryDate() {
- return formatDate(this.expiresAt, 'yyyy-mm-dd');
+ return this.expiresAt ? formatDate(this.expiresAt, 'yyyy-mm-dd') : '';
},
newTokenCreatedMessage() {
return this.tokenType === 'group'
@@ -129,6 +130,9 @@ export default {
name: this.name,
read_repository: this.readRepository,
read_registry: this.readRegistry,
+ write_registry: this.writeRegistry,
+ read_package_registry: this.readPackageRegistry,
+ write_package_registry: this.writePackageRegistry,
username: this.username,
},
})
@@ -142,7 +146,8 @@ export default {
})
.catch((error) => {
createAlert({
- message: error.response.data.message,
+ message:
+ error?.response?.data?.message || this.$options.translations.createTokenFailedAlert,
});
});
},
@@ -228,13 +233,7 @@ export default {
:description="$options.translations.addTokenNameDescription"
label-for="deploy_token_name"
>
- <gl-form-input
- id="deploy_token_name"
- v-model="name"
- name="deploy_token_name"
- class="qa-deploy-token-name"
- data-qa-selector="deploy_token_name_field"
- />
+ <gl-form-input id="deploy_token_name" v-model="name" name="deploy_token_name" />
</gl-form-group>
<gl-form-group
:label="$options.translations.addTokenExpiryLabel"
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index fba73cd4bec..ab003fb2879 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -349,7 +349,7 @@ export default {
<template>
<div
data-testid="designs-root"
- class="gl-mt-5"
+ class="gl-mt-4"
@mouseenter="toggleOnPasteListener"
@mouseleave="toggleOffPasteListener"
>
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index bc49464a560..7bc75127876 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -471,8 +471,14 @@ export default {
},
fetchData(toggleTree = true) {
this.fetchDiffFilesMeta()
- .then(({ real_size = 0 }) => {
- this.diffFilesLength = parseInt(real_size, 10) || 0;
+ .then((data) => {
+ let realSize = 0;
+
+ if (data) {
+ realSize = data.real_size;
+ }
+
+ this.diffFilesLength = parseInt(realSize, 10) || 0;
if (toggleTree) {
this.setTreeDisplay();
}
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index 25d3bda147b..9e399a642d0 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -88,6 +88,7 @@ export default {
:discussions-by-diff-order="true"
:line="line"
:help-page-path="helpPagePath"
+ :should-scroll-to-note="false"
@noteDeleted="deleteNoteHandler"
>
<template v-if="renderAvatarBadge" #avatar-badge>
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 422bf52a1fa..8f041d1e670 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -393,7 +393,7 @@ export default {
v-else-if="conflictResolutionPath"
:message="
__(
- 'You can %{gitlabLinkStart}resolve conflicts on GitLab%{gitlabLinkEnd} or %{resolveLocallyStart}resolve it locally%{resolveLocallyEnd}.',
+ 'You can %{gitlabLinkStart}resolve conflicts on GitLab%{gitlabLinkEnd} or %{resolveLocallyStart}resolve them locally%{resolveLocallyEnd}.',
)
"
>
@@ -415,7 +415,7 @@ export default {
</gl-sprintf>
<gl-sprintf
v-else
- :message="__('You can %{resolveLocallyStart}resolve it locally%{resolveLocallyEnd}.')"
+ :message="__('You can %{resolveLocallyStart}resolve them locally%{resolveLocallyEnd}.')"
>
<template #resolveLocally="{ content }">
<gl-button
diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js
index 7732badde34..479853caae3 100644
--- a/app/assets/javascripts/diffs/components/diff_row_utils.js
+++ b/app/assets/javascripts/diffs/components/diff_row_utils.js
@@ -57,21 +57,32 @@ export const classNameMapCell = ({ line, hll, isLoggedIn, isHover }) => {
export const addCommentTooltip = (line) => {
let tooltip;
- if (!line) return tooltip;
+ if (!line) {
+ return tooltip;
+ }
tooltip = __('Add a comment to this line or drag for multiple lines');
- const brokenSymlinks = line.commentsDisabled;
- if (brokenSymlinks) {
- if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) {
+ if (!line.problems) {
+ return tooltip;
+ }
+
+ const { brokenSymlink, brokenLineCode, fileOnlyMoved } = line.problems;
+
+ if (brokenSymlink) {
+ if (brokenSymlink.wasSymbolic || brokenSymlink.isSymbolic) {
tooltip = __(
- 'Commenting on symbolic links that replace or are replaced by files is currently not supported.',
+ 'Commenting on symbolic links that replace or are replaced by files is not supported',
);
- } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) {
+ } else if (brokenSymlink.wasReal || brokenSymlink.isReal) {
tooltip = __(
- 'Commenting on files that replace or are replaced by symbolic links is currently not supported.',
+ 'Commenting on files that replace or are replaced by symbolic links is not supported',
);
}
+ } else if (fileOnlyMoved) {
+ tooltip = __('Commenting on files that are only moved or renamed is not supported');
+ } else if (brokenLineCode) {
+ tooltip = __('Commenting on this line is not supported');
}
return tooltip;
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 5234be44b05..c73012527a2 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 } from '~/flash';
+import { createAlert, VARIANT_WARNING } from '~/flash';
import { diffViewerModes } from '~/ide/constants';
import axios from '~/lib/utils/axios_utils';
@@ -229,9 +229,17 @@ export const fetchDiffFilesMeta = ({ commit, state }) => {
return data;
})
- .catch(() => worker.terminate());
-};
+ .catch((error) => {
+ worker.terminate();
+ if (error.response.status === httpStatusCodes.NOT_FOUND) {
+ createAlert({
+ message: __('Building your merge request. Wait a few moments, then refresh this page.'),
+ variant: VARIANT_WARNING,
+ });
+ }
+ });
+};
export const fetchCoverageFiles = ({ commit, state }) => {
const coveragePoll = new Poll({
resource: {
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index cf86ebea4a9..0519ca3d715 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -324,15 +324,24 @@ function cleanRichText(text) {
}
function prepareLine(line, file) {
+ const problems = {
+ brokenSymlink: file.brokenSymlink,
+ brokenLineCode: !line.line_code,
+ fileOnlyMoved: file.renamed_file && file.added_lines === 0 && file.removed_lines === 0,
+ };
+
if (!line.alreadyPrepared) {
Object.assign(line, {
- commentsDisabled: file.brokenSymlink,
+ commentsDisabled: Boolean(
+ problems.brokenSymlink || problems.fileOnlyMoved || problems.brokenLineCode,
+ ),
rich_text: cleanRichText(line.rich_text),
discussionsExpanded: true,
discussions: [],
hasForm: false,
text: undefined,
alreadyPrepared: true,
+ problems,
});
}
}
diff --git a/app/assets/javascripts/diffs/utils/tree_worker_utils.js b/app/assets/javascripts/diffs/utils/tree_worker_utils.js
index 985e75d1a17..a90c1a5c64e 100644
--- a/app/assets/javascripts/diffs/utils/tree_worker_utils.js
+++ b/app/assets/javascripts/diffs/utils/tree_worker_utils.js
@@ -62,10 +62,15 @@ export const generateTreeList = (files) => {
const split = file.new_path.split('/');
split.forEach((name, i) => {
- const parent = acc.treeEntries[split.slice(0, i).join('/')];
+ let parent = acc.treeEntries[split.slice(0, i).join('/')];
const path = `${parent ? `${parent.path}/` : ''}${name}`;
+ const child = acc.treeEntries[path];
- if (!acc.treeEntries[path]) {
+ if (parent && !parent.tree) {
+ parent = null;
+ }
+
+ if (!child || !child.tree) {
const type = path === file.new_path ? 'blob' : 'tree';
acc.treeEntries[path] = {
key: path,
diff --git a/app/assets/javascripts/dirty_submit/dirty_submit_form.js b/app/assets/javascripts/dirty_submit/dirty_submit_form.js
index 83dd4b0a124..941c42f75eb 100644
--- a/app/assets/javascripts/dirty_submit/dirty_submit_form.js
+++ b/app/assets/javascripts/dirty_submit/dirty_submit_form.js
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import { memoize, throttle } from 'lodash';
import createEventHub from '~/helpers/event_hub_factory';
@@ -34,7 +33,6 @@ class DirtySubmitForm {
this.form.addEventListener('input', throttledUpdateDirtyInput);
this.form.addEventListener('change', throttledUpdateDirtyInput);
- $(this.form).on('change.select2', throttledUpdateDirtyInput);
this.form.addEventListener('submit', (event) => this.formSubmit(event));
}
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 999e91eed19..dd4a7a689d7 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,4 +1,4 @@
-import { KeyMod, KeyCode } from 'monaco-editor';
+import { KeyMod, KeyCode, Emitter } from 'monaco-editor';
import { debounce } from 'lodash';
import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants';
import { createAlert } from '~/flash';
@@ -56,6 +56,7 @@ export class EditorMarkdownPreviewExtension {
layoutChangeListener: undefined,
path: setupOptions.previewMarkdownPath,
actionShowPreviewCondition: instance.createContextKey('toggleLivePreview', true),
+ eventEmitter: new Emitter(),
};
this.toolbarButtons = [];
@@ -71,6 +72,8 @@ export class EditorMarkdownPreviewExtension {
EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth);
}
});
+
+ this.preview.eventEmitter.event(this.togglePreview.bind(this, instance));
}
onBeforeUnuse(instance) {
@@ -187,6 +190,31 @@ export class EditorMarkdownPreviewExtension {
});
}
+ togglePreview(instance) {
+ if (!this.preview?.el) {
+ this.preview.el = setupDomElement({ injectToEl: instance.getDomNode().parentElement });
+ }
+ this.togglePreviewLayout(instance);
+ this.togglePreviewPanel(instance);
+
+ this.preview.actionShowPreviewCondition.set(!this.preview.actionShowPreviewCondition.get());
+
+ if (!this.preview?.shown) {
+ this.preview.modelChangeListener = instance.onDidChangeModelContent(
+ debounce(this.fetchPreview.bind(this, instance), EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY),
+ );
+ } else {
+ this.preview.modelChangeListener.dispose();
+ }
+
+ this.preview.shown = !this.preview?.shown;
+ if (instance.toolbar) {
+ instance.toolbar.updateItem(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, {
+ selected: this.preview.shown,
+ });
+ }
+ }
+
provides() {
return {
markdownPreview: this.preview,
@@ -195,33 +223,7 @@ export class EditorMarkdownPreviewExtension {
setupPreviewAction: (instance) => this.setupPreviewAction(instance),
- togglePreview: (instance) => {
- if (!this.preview?.el) {
- this.preview.el = setupDomElement({ injectToEl: instance.getDomNode().parentElement });
- }
- this.togglePreviewLayout(instance);
- this.togglePreviewPanel(instance);
-
- this.preview.actionShowPreviewCondition.set(!this.preview.actionShowPreviewCondition.get());
-
- if (!this.preview?.shown) {
- this.preview.modelChangeListener = instance.onDidChangeModelContent(
- debounce(
- this.fetchPreview.bind(this, instance),
- EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
- ),
- );
- } else {
- this.preview.modelChangeListener.dispose();
- }
-
- this.preview.shown = !this.preview?.shown;
- if (instance.toolbar) {
- instance.toolbar.updateItem(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, {
- selected: this.preview.shown,
- });
- }
- },
+ togglePreview: (instance) => this.togglePreview(instance),
};
}
}
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index e56932a9a31..45f063a2048 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -103,7 +103,9 @@
"workflow": {
"type": "object",
"properties": {
- "name": { "$ref": "#/definitions/workflowName" },
+ "name": {
+ "$ref": "#/definitions/workflowName"
+ },
"rules": {
"type": "array",
"items": {
@@ -130,7 +132,7 @@
"$ref": "#/definitions/exists"
},
"variables": {
- "$ref": "#/definitions/variables"
+ "$ref": "#/definitions/rulesVariables"
},
"when": {
"type": "string",
@@ -688,7 +690,7 @@
"$ref": "#/definitions/exists"
},
"variables": {
- "$ref": "#/definitions/variables"
+ "$ref": "#/definitions/rulesVariables"
},
"when": {
"$ref": "#/definitions/when"
@@ -742,6 +744,10 @@
"description": {
"type": "string",
"markdownDescription": "Explains what the variable is used for, what the acceptable values are. Variables with `description` are prefilled when running a pipeline manually. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variablesdescription)."
+ },
+ "expand": {
+ "type": "boolean",
+ "markdownDescription": "If the variable is expandable or not. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variablesexpand)."
}
},
"additionalProperties": false
@@ -751,6 +757,49 @@
"additionalProperties": false
}
},
+ "jobVariables": {
+ "markdownDescription": "Defines variables for a job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variables).",
+ "type": "object",
+ "patternProperties": {
+ ".*": {
+ "oneOf": [
+ {
+ "type": [
+ "string",
+ "number"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "value": {
+ "type": "string"
+ },
+ "expand": {
+ "type": "boolean",
+ "markdownDescription": "Defines if the variable is expandable or not. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variablesexpand)."
+ }
+ },
+ "additionalProperties": false
+ }
+ ]
+ },
+ "additionalProperties": false
+ }
+ },
+ "rulesVariables": {
+ "markdownDescription": "Defines variables for a rule result. [Learn More](https://docs.gitlab.com/ee/ci/yaml/index.html#rulesvariables).",
+ "type": "object",
+ "patternProperties": {
+ ".*": {
+ "type": [
+ "string",
+ "number"
+ ]
+ },
+ "additionalProperties": false
+ }
+ },
"if": {
"type": "string",
"markdownDescription": "Expression to evaluate whether additional attributes should be provided to the job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#rulesif)."
@@ -793,19 +842,6 @@
"type": "string"
}
},
- "variables": {
- "markdownDescription": "Defines environment variables. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variables).",
- "type": "object",
- "patternProperties": {
- ".*": {
- "type": [
- "string",
- "number"
- ]
- },
- "additionalProperties": false
- }
- },
"timeout": {
"type": "string",
"markdownDescription": "Allows you to configure a timeout for a specific job (e.g. `1 minute`, `1h 30m 12s`). [Learn More](https://docs.gitlab.com/ee/ci/yaml/index.html#timeout).",
@@ -858,137 +894,77 @@
]
},
"when": {
- "markdownDescription": "Describes the conditions for when to run the job. Defaults to 'on_success'.",
+ "markdownDescription": "Describes the conditions for when to run the job. Defaults to 'on_success'. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#when).",
"default": "on_success",
- "oneOf": [
- {
- "enum": [
- "on_success"
- ],
- "description": "Execute job only when all jobs from prior stages succeed."
- },
- {
- "enum": [
- "on_failure"
- ],
- "description": "Execute job when at least one job from prior stages fails."
- },
- {
- "enum": [
- "always"
- ],
- "description": "Execute job regardless of the status from prior stages."
- },
- {
- "enum": [
- "manual"
- ],
- "markdownDescription": "Execute the job manually from Gitlab UI or API. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#when)."
- },
- {
- "enum": [
- "delayed"
- ],
- "markdownDescription": "Execute a job after the time limit in 'start_in' expires. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#when)."
- },
- {
- "enum": [
- "never"
- ],
- "description": "Never execute the job."
- }
+ "type": "string",
+ "enum": [
+ "on_success",
+ "on_failure",
+ "always",
+ "never",
+ "manual",
+ "delayed"
]
},
"cache": {
+ "markdownDescription": "Use `cache` to specify a list of files and directories to cache between jobs. You can only use paths that are in the local working copy. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#cache)",
"properties": {
- "when": {
- "markdownDescription": "Defines when to save the cache, based on the status of the job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#cachewhen).",
- "default": "on_success",
- "oneOf": [
- {
- "enum": [
- "on_success"
- ],
- "description": "Save the cache only when the job succeeds."
- },
- {
- "enum": [
- "on_failure"
- ],
- "description": "Save the cache only when the job fails. "
- },
- {
- "enum": [
- "always"
- ],
- "description": "Always save the cache. "
- }
- ]
- }
- }
- },
- "cache_entry": {
- "type": "object",
- "description": "Specify files or directories to cache between jobs. Can be set globally or per job.",
- "additionalProperties": false,
- "properties": {
- "paths": {
- "type": "array",
- "description": "List of files or paths to cache.",
- "items": {
- "type": "string"
- }
- },
"key": {
+ "markdownDescription": "Use the `cache:key` keyword to give each cache a unique identifying key. All jobs that use the same cache key use the same cache, including in different pipelines. Must be used with `cache:path`, or nothing is cached. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#cachekey).",
"oneOf": [
{
"type": "string",
- "description": "Unique cache ID, to allow e.g. specific branch or job cache. Environment variables can be used to set up unique keys (e.g. \"$CI_COMMIT_REF_SLUG\" for per branch cache)."
+ "pattern": "^(?!.*\\/)^(.*[^.]+.*)$"
},
{
"type": "object",
- "description": "When you include cache:key:files, you must also list the project files that will be used to generate the key, up to a maximum of two files. The cache key will be a SHA checksum computed from the most recent commits (up to two, if two files are listed) that changed the given files.",
"properties": {
"files": {
+ "markdownDescription": "Use the `cache:key:files` keyword to generate a new key when one or two specific files change. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#cachekeyfiles)",
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"maxItems": 2
+ },
+ "prefix": {
+ "markdownDescription": "Use `cache:key:prefix` to combine a prefix with the SHA computed for `cache:key:files`. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#cachekeyprefix)",
+ "type": "string"
}
}
}
]
},
- "untracked": {
- "type": "boolean",
- "description": "Set to `true` to cache untracked files.",
- "default": false
+ "paths": {
+ "type": "array",
+ "markdownDescription": "Use the `cache:paths` keyword to choose which files or directories to cache. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#cachepaths)",
+ "items": {
+ "type": "string"
+ }
},
"policy": {
"type": "string",
- "description": "Determines the strategy for downloading and updating the cache.",
+ "markdownDescription": "Determines the strategy for downloading and updating the cache. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#cachepolicy)",
"default": "pull-push",
- "oneOf": [
- {
- "enum": [
- "pull"
- ],
- "description": "Pull will download cache but skip uploading after job completes."
- },
- {
- "enum": [
- "push"
- ],
- "description": "Push will skip downloading cache and always recreate cache after job completes."
- },
- {
- "enum": [
- "pull-push"
- ],
- "description": "Pull-push will both download cache at job start and upload cache on job success."
- }
+ "enum": [
+ "pull",
+ "push",
+ "pull-push"
+ ]
+ },
+ "untracked": {
+ "type": "boolean",
+ "markdownDescription": "Use `untracked: true` to cache all files that are untracked in your Git repository. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#cacheuntracked)",
+ "default": false
+ },
+ "when": {
+ "markdownDescription": "Defines when to save the cache, based on the status of the job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#cachewhen).",
+ "default": "on_success",
+ "enum": [
+ "on_success",
+ "on_failure",
+ "always"
]
}
}
@@ -1228,7 +1204,7 @@
"$ref": "#/definitions/rules"
},
"variables": {
- "$ref": "#/definitions/variables"
+ "$ref": "#/definitions/jobVariables"
},
"cache": {
"$ref": "#/definitions/cache"
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index c7e024aadec..74eef50ebaf 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -57,7 +57,8 @@ export default {
this.isLoading = true;
if (this.graphql) {
- this.$apollo.mutate({ mutation: actionMutation, variables: { action } });
+ await this.$apollo.mutate({ mutation: actionMutation, variables: { action } });
+ this.isLoading = false;
} else {
eventHub.$emit('postAction', { endpoint: action.playPath });
}
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index f7f0cf4cb8d..f7a853f3128 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -51,17 +51,20 @@ export default {
methods: {
onClick() {
+ const rollbackEnvironmentData = {
+ ...this.environment,
+ retryUrl: this.retryUrl,
+ isLastDeployment: this.isLastDeployment,
+ };
if (this.graphql) {
this.$apollo.mutate({
mutation: setEnvironmentToRollback,
- variables: { environment: this.environment },
+ variables: {
+ environment: rollbackEnvironmentData,
+ },
});
} else {
- eventHub.$emit('requestRollbackEnvironment', {
- ...this.environment,
- retryUrl: this.retryUrl,
- isLastDeployment: this.isLastDeployment,
- });
+ eventHub.$emit('requestRollbackEnvironment', rollbackEnvironmentData);
}
},
},
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_to_rollback.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_to_rollback.query.graphql
index f7586e27665..84c6998f234 100644
--- a/app/assets/javascripts/environments/graphql/queries/environment_to_rollback.query.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/environment_to_rollback.query.graphql
@@ -3,5 +3,6 @@ query environmentToRollback {
id
name
lastDeployment
+ retryUrl
}
}
diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js
index 26507a85fa8..fe580aab108 100644
--- a/app/assets/javascripts/filtered_search/dropdown_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_user.js
@@ -1,3 +1,4 @@
+import { ACTIVE_AND_BLOCKED_USER_STATES } from '~/users_select/constants';
import { addClassIfElementExists } from '../lib/utils/dom_utils';
import DropdownAjaxFilter from './dropdown_ajax_filter';
@@ -14,7 +15,7 @@ export default class DropdownUser extends DropdownAjaxFilter {
return {
...super.ajaxFilterConfig(),
params: {
- active: true,
+ states: ACTIVE_AND_BLOCKED_USER_STATES,
group_id: this.getGroupId(),
project_id: this.getProjectId(),
current_user: true,
diff --git a/app/assets/javascripts/filtered_search/droplab/plugins/ajax_filter.js b/app/assets/javascripts/filtered_search/droplab/plugins/ajax_filter.js
index d0f2d205bb6..d6abab4c9ed 100644
--- a/app/assets/javascripts/filtered_search/droplab/plugins/ajax_filter.js
+++ b/app/assets/javascripts/filtered_search/droplab/plugins/ajax_filter.js
@@ -1,6 +1,7 @@
/* eslint-disable */
import AjaxCache from '~/lib/utils/ajax_cache';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
const AjaxFilter = {
init: function (hook) {
@@ -62,7 +63,7 @@ const AjaxFilter = {
this.loading = true;
var params = config.params || {};
params[config.searchKey] = searchValue;
- var url = config.endpoint + this.buildParams(params);
+ var url = mergeUrlParams(params, config.endpoint, { spreadArrays: true });
return AjaxCache.retrieve(url)
.then((data) => {
this._loadData(data, config);
diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
index acb7449f830..d6e7887f93f 100644
--- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
@@ -1,10 +1,18 @@
import { flattenDeep } from 'lodash';
-import { __, s__ } from '~/locale';
+import { __ } from '~/locale';
+import {
+ TOKEN_TITLE_ASSIGNEE,
+ TOKEN_TITLE_AUTHOR,
+ TOKEN_TITLE_LABEL,
+ TOKEN_TITLE_MILESTONE,
+ TOKEN_TITLE_MY_REACTION,
+ TOKEN_TITLE_RELEASE,
+} from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
export const tokenKeys = [
{
- formattedKey: __('Author'),
+ formattedKey: TOKEN_TITLE_AUTHOR,
key: 'author',
type: 'string',
param: 'username',
@@ -13,7 +21,7 @@ export const tokenKeys = [
tag: '@author',
},
{
- formattedKey: s__('SearchToken|Assignee'),
+ formattedKey: TOKEN_TITLE_ASSIGNEE,
key: 'assignee',
type: 'string',
param: 'username',
@@ -22,7 +30,7 @@ export const tokenKeys = [
tag: '@assignee',
},
{
- formattedKey: __('Milestone'),
+ formattedKey: TOKEN_TITLE_MILESTONE,
key: 'milestone',
type: 'string',
param: 'title',
@@ -31,7 +39,7 @@ export const tokenKeys = [
tag: '%milestone',
},
{
- formattedKey: __('Release'),
+ formattedKey: TOKEN_TITLE_RELEASE,
key: 'release',
type: 'string',
param: 'tag',
@@ -40,7 +48,7 @@ export const tokenKeys = [
tag: __('tag name'),
},
{
- formattedKey: __('Label'),
+ formattedKey: TOKEN_TITLE_LABEL,
key: 'label',
type: 'array',
param: 'name[]',
@@ -53,7 +61,7 @@ export const tokenKeys = [
if (gon.current_user_id) {
// Appending tokenkeys only logged-in
tokenKeys.push({
- formattedKey: __('My-Reaction'),
+ formattedKey: TOKEN_TITLE_MY_REACTION,
key: 'my-reaction',
type: 'string',
param: 'emoji',
@@ -65,7 +73,7 @@ if (gon.current_user_id) {
export const alternativeTokenKeys = [
{
- formattedKey: __('Label'),
+ formattedKey: TOKEN_TITLE_LABEL,
key: 'label',
type: 'string',
param: 'name',
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index 5665231e613..dc6c4642e94 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -109,6 +109,7 @@ const addDismissFlashClickListener = (flashEl, fadeTransition) => {
*
* @param {object} options - Options to control the flash message
* @param {string} options.message - Alert message text
+ * @param {string} [options.title] - Alert title
* @param {VARIANT_SUCCESS|VARIANT_WARNING|VARIANT_DANGER|VARIANT_INFO|VARIANT_TIP} [options.variant] - Which GlAlert variant to use; it defaults to VARIANT_DANGER.
* @param {object} [options.parent] - Reference to parent element under which alert needs to appear. Defaults to `document`.
* @param {Function} [options.onDismiss] - Handler to call when this alert is dismissed.
@@ -126,6 +127,7 @@ const addDismissFlashClickListener = (flashEl, fadeTransition) => {
*/
const createAlert = function createAlert({
message,
+ title,
variant = VARIANT_DANGER,
parent = document,
containerSelector = '.flash-container',
@@ -183,6 +185,7 @@ const createAlert = function createAlert({
GlAlert,
{
props: {
+ title,
dismissible: true,
dismissLabel: __('Dismiss'),
variant,
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 01d218438cf..49c47e9d778 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -20,7 +20,12 @@ const MERGEREQUESTS_ALIAS = 'mergerequests';
const LABELS_ALIAS = 'labels';
const SNIPPETS_ALIAS = 'snippets';
const CONTACTS_ALIAS = 'contacts';
+
export const AT_WHO_ACTIVE_CLASS = 'at-who-active';
+export const CONTACT_STATE_ACTIVE = 'active';
+export const CONTACTS_ADD_COMMAND = '/add_contacts';
+export const CONTACTS_REMOVE_COMMAND = '/remove_contacts';
+
/**
* Escapes user input before we pass it to at.js, which
* renders it as HTML in the autocomplete dropdown.
@@ -666,6 +671,9 @@ class GfmAutoComplete {
}
setupContacts($input) {
+ const fetchData = this.fetchData.bind(this);
+ let command = '';
+
$input.atwho({
at: '[contact:',
suffix: ']',
@@ -694,9 +702,44 @@ class GfmAutoComplete {
firstName: m.first_name,
lastName: m.last_name,
search: `${m.email}`,
+ state: m.state,
+ set: m.set,
};
});
},
+ matcher(flag, subtext) {
+ const subtextNodes = subtext.split(/\n+/g).pop().split(GfmAutoComplete.regexSubtext);
+
+ command = subtextNodes.find((node) => {
+ if (node === CONTACTS_ADD_COMMAND || node === CONTACTS_REMOVE_COMMAND) {
+ return node;
+ }
+ return null;
+ });
+
+ const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
+ return match?.length ? match[1] : null;
+ },
+ filter(query, data, searchKey) {
+ if (GfmAutoComplete.isLoading(data)) {
+ fetchData(this.$inputor, this.at);
+ return data;
+ }
+
+ if (data === GfmAutoComplete.defaultLoadingData) {
+ return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
+ }
+
+ if (command === CONTACTS_ADD_COMMAND) {
+ // Return contacts that are active and not already on the issue
+ return data.filter((contact) => contact.state === CONTACT_STATE_ACTIVE && !contact.set);
+ } else if (command === CONTACTS_REMOVE_COMMAND) {
+ // Return contacts already on the issue
+ return data.filter((contact) => contact.set);
+ }
+
+ return data;
+ },
},
});
showAndHideHelper($input, CONTACTS_ALIAS);
diff --git a/app/assets/javascripts/gitlab_version_check.js b/app/assets/javascripts/gitlab_version_check.js
deleted file mode 100644
index 2892aded7c5..00000000000
--- a/app/assets/javascripts/gitlab_version_check.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Vue from 'vue';
-import GitlabVersionCheck from '~/vue_shared/components/gitlab_version_check.vue';
-
-const mountGitlabVersionCheck = (el) => {
- const { size } = el.dataset;
-
- return new Vue({
- el,
- render(createElement) {
- return createElement(GitlabVersionCheck, {
- props: {
- size,
- },
- });
- },
- });
-};
-
-export default () =>
- [...document.querySelectorAll('.js-gitlab-version-check')].map(mountGitlabVersionCheck);
diff --git a/app/assets/javascripts/gitlab_version_check/components/gitlab_version_check_badge.vue b/app/assets/javascripts/gitlab_version_check/components/gitlab_version_check_badge.vue
new file mode 100644
index 00000000000..1536a9a525b
--- /dev/null
+++ b/app/assets/javascripts/gitlab_version_check/components/gitlab_version_check_badge.vue
@@ -0,0 +1,73 @@
+<script>
+import { GlBadge } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+import { STATUS_TYPES, UPGRADE_DOCS_URL } from '../constants';
+
+export default {
+ name: 'GitlabVersionCheckBadge',
+ components: {
+ GlBadge,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ size: {
+ type: String,
+ required: false,
+ default: 'md',
+ },
+ actionable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ status: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ title() {
+ if (this.status === STATUS_TYPES.SUCCESS) {
+ return s__('VersionCheck|Up to date');
+ } else if (this.status === STATUS_TYPES.WARNING) {
+ return s__('VersionCheck|Update available');
+ } else if (this.status === STATUS_TYPES.DANGER) {
+ return s__('VersionCheck|Update ASAP');
+ }
+
+ return null;
+ },
+ badgeUrl() {
+ return this.actionable ? UPGRADE_DOCS_URL : null;
+ },
+ },
+ mounted() {
+ this.track('render', {
+ label: 'version_badge',
+ property: this.title,
+ });
+ },
+ methods: {
+ onClick() {
+ if (!this.actionable) return;
+
+ this.track('click_link', {
+ label: 'version_badge',
+ property: this.title,
+ });
+ },
+ },
+ UPGRADE_DOCS_URL,
+};
+</script>
+
+<template>
+ <!-- TODO: remove the span element once bootstrap-vue is updated to version 2.21.1 -->
+ <!-- TODO: https://github.com/bootstrap-vue/bootstrap-vue/issues/6219 -->
+ <span data-testid="badge-click-wrapper" @click="onClick">
+ <gl-badge :href="badgeUrl" class="version-check-badge" :variant="status" :size="size">{{
+ title
+ }}</gl-badge>
+ </span>
+</template>
diff --git a/app/assets/javascripts/gitlab_version_check/constants.js b/app/assets/javascripts/gitlab_version_check/constants.js
new file mode 100644
index 00000000000..259723a4e22
--- /dev/null
+++ b/app/assets/javascripts/gitlab_version_check/constants.js
@@ -0,0 +1,9 @@
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export const STATUS_TYPES = {
+ SUCCESS: 'success',
+ WARNING: 'warning',
+ DANGER: 'danger',
+};
+
+export const UPGRADE_DOCS_URL = helpPagePath('update/index');
diff --git a/app/assets/javascripts/gitlab_version_check/index.js b/app/assets/javascripts/gitlab_version_check/index.js
new file mode 100644
index 00000000000..203ce10ef57
--- /dev/null
+++ b/app/assets/javascripts/gitlab_version_check/index.js
@@ -0,0 +1,50 @@
+import Vue from 'vue';
+import * as Sentry from '@sentry/browser';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import axios from '~/lib/utils/axios_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
+import GitlabVersionCheckBadge from './components/gitlab_version_check_badge.vue';
+
+const mountGitlabVersionCheckBadge = ({ el, status }) => {
+ const { size } = el.dataset;
+ const actionable = parseBoolean(el.dataset.actionable);
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(GitlabVersionCheckBadge, {
+ props: {
+ size,
+ actionable,
+ status,
+ },
+ });
+ },
+ });
+};
+
+export default async () => {
+ const versionCheckBadges = [...document.querySelectorAll('.js-gitlab-version-check-badge')];
+
+ // If there are no version check elements, exit out
+ if (versionCheckBadges?.length <= 0) {
+ return null;
+ }
+
+ const status = await axios
+ .get(joinPaths('/', gon.relative_url_root, '/admin/version_check.json'))
+ .then((res) => {
+ return res.data?.severity;
+ })
+ .catch((e) => {
+ Sentry.captureException(e);
+ return null;
+ });
+
+ // If we don't have a status there is nothing to render
+ if (status) {
+ return versionCheckBadges.map((el) => mountGitlabVersionCheckBadge({ el, status }));
+ }
+
+ return null;
+};
diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js
index eec7a138ea7..28aa9906116 100644
--- a/app/assets/javascripts/gl_field_errors.js
+++ b/app/assets/javascripts/gl_field_errors.js
@@ -15,9 +15,14 @@ export default class GlFieldErrors {
initValidators() {
// register selectors here as needed
- const validateSelectors = [':text', ':password', '[type=email]', '[type=url]', '[type=number]']
- .map((selector) => `input${selector}`)
- .join(',');
+ const validateSelectors = [
+ 'input:text',
+ 'input:password',
+ 'input[type=email]',
+ 'input[type=url]',
+ 'input[type=number]',
+ 'textarea',
+ ].join(',');
this.state.inputs = this.form
.find(validateSelectors)
diff --git a/app/assets/javascripts/google_cloud/service_accounts/list.vue b/app/assets/javascripts/google_cloud/service_accounts/list.vue
index 4b580c594f5..c9d9a9a3e8c 100644
--- a/app/assets/javascripts/google_cloud/service_accounts/list.vue
+++ b/app/assets/javascripts/google_cloud/service_accounts/list.vue
@@ -1,7 +1,10 @@
<script>
import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf, GlTable } from '@gitlab/ui';
+import { setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
+const GOOGLE_CONSOLE_URL = 'https://console.cloud.google.com/iam-admin/serviceaccounts';
+
export default {
components: { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf, GlTable },
props: {
@@ -40,6 +43,12 @@ export default {
'Enhance security by storing service account keys in secret managers - learn more about %{docLinkStart}secret management with GitLab%{docLinkEnd}',
),
},
+ methods: {
+ gcpProjectUrl(id) {
+ return setUrlParams({ project: id }, GOOGLE_CONSOLE_URL);
+ },
+ },
+ GOOGLE_CONSOLE_URL,
};
</script>
@@ -59,6 +68,9 @@ export default {
<p>{{ $options.i18n.serviceAccountsDescription }}</p>
<gl-table :items="list" :fields="$options.tableFields">
+ <template #cell(gcp_project)="{ value }">
+ <gl-link :href="gcpProjectUrl(value)">{{ value }}</gl-link>
+ </template>
<template #cell(service_account_exists)="{ value }">
{{ value ? $options.i18n.found : $options.i18n.notFound }}
</template>
diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js
index 5b0bcfa963b..98c9db1fc9a 100644
--- a/app/assets/javascripts/google_tag_manager/index.js
+++ b/app/assets/javascripts/google_tag_manager/index.js
@@ -40,6 +40,11 @@ const generateProductInfo = (sku, quantity) => {
};
const isSupported = () => Boolean(window.dataLayer) && gon.features?.gitlabGtmDatalayer;
+// gon.features.gitlabGtmDatalayer is set by writing
+// `push_frontend_feature_flag(:gitlab_gtm_datalayer, type: :ops)`
+// to the appropriate controller
+// window.dataLayer is set by adding partials to the appropriate view found in
+// views/layouts/_google_tag_manager_body.html.haml and _google_tag_manager_head.html.haml
const pushEvent = (event, args = {}) => {
if (!window.dataLayer) {
@@ -287,3 +292,27 @@ export const trackCompanyForm = (aboutYourCompanyType) => {
pushEvent('aboutYourCompanyFormSubmit', { aboutYourCompanyType });
};
+
+export const saasTrialWelcome = () => {
+ if (!isSupported()) {
+ return;
+ }
+
+ const saasTrialWelcomeButton = document.querySelector('.js-trial-welcome-btn');
+
+ saasTrialWelcomeButton?.addEventListener('click', () => {
+ pushEvent('saasTrialWelcome');
+ });
+};
+
+export const saasTrialContinuousOnboarding = () => {
+ if (!isSupported()) {
+ return;
+ }
+
+ const getStartedButton = document.querySelector('.js-get-started-btn');
+
+ getStartedButton?.addEventListener('click', () => {
+ pushEvent('saasTrialContinuousOnboarding');
+ });
+};
diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js
index 3b737dfff33..15e7ef7d62c 100644
--- a/app/assets/javascripts/graphql_shared/issuable_client.js
+++ b/app/assets/javascripts/graphql_shared/issuable_client.js
@@ -1,16 +1,18 @@
import produce from 'immer';
import VueApollo from 'vue-apollo';
+import { defaultDataIdFromObject } from '@apollo/client/core';
import { concatPagination } from '@apollo/client/utilities';
import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql';
import createDefaultClient from '~/lib/graphql';
import typeDefs from '~/work_items/graphql/typedefs.graphql';
-import { WIDGET_TYPE_MILESTONE } from '~/work_items/constants';
-export const temporaryConfig = {
+export const config = {
typeDefs,
cacheConfig: {
- possibleTypes: {
- LocalWorkItemWidget: ['LocalWorkItemMilestone'],
+ // included temporarily until Vuex is removed from boards app
+ dataIdFromObject: (object) => {
+ // eslint-disable-next-line no-underscore-dangle
+ return object.__typename === 'BoardList' ? object.iid : defaultDataIdFromObject(object);
},
typePolicies: {
Project: {
@@ -22,35 +24,15 @@ export const temporaryConfig = {
},
WorkItem: {
fields: {
- mockWidgets: {
- read(widgets) {
- return (
- widgets || [
- {
- __typename: 'LocalWorkItemMilestone',
- type: WIDGET_TYPE_MILESTONE,
- nodes: [
- {
- dueDate: null,
- expired: false,
- id: 'gid://gitlab/Milestone/30',
- title: 'v4.0',
- // eslint-disable-next-line @gitlab/require-i18n-strings
- __typename: 'Milestone',
- },
- ],
- },
- ]
- );
- },
- },
widgets: {
merge(existing = [], incoming) {
if (existing.length === 0) {
return incoming;
}
return existing.map((existingWidget) => {
- const incomingWidget = incoming.find((w) => w.type === existingWidget.type);
+ const incomingWidget = incoming.find(
+ (w) => w.type && w.type === existingWidget.type,
+ );
return incomingWidget || existingWidget;
});
},
@@ -78,7 +60,7 @@ export const resolvers = {
},
};
-export const defaultClient = createDefaultClient(resolvers, temporaryConfig);
+export const defaultClient = createDefaultClient(resolvers, config);
export const apolloProvider = new VueApollo({
defaultClient,
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index 545c150e536..e8b0174b8f6 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -9,6 +9,10 @@
"CiManualVariable",
"CiProjectVariable"
],
+ "CommitSignature": [
+ "GpgSignature",
+ "X509Signature"
+ ],
"CurrentUserTodos": [
"BoardEpic",
"Design",
@@ -143,8 +147,9 @@
"WorkItemWidgetHierarchy",
"WorkItemWidgetIteration",
"WorkItemWidgetLabels",
+ "WorkItemWidgetMilestone",
"WorkItemWidgetStartAndDueDate",
"WorkItemWidgetStatus",
"WorkItemWidgetWeight"
]
-}
+} \ No newline at end of file
diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue
index d0c5846ac88..46ab30367a0 100644
--- a/app/assets/javascripts/groups/components/overview_tabs.vue
+++ b/app/assets/javascripts/groups/components/overview_tabs.vue
@@ -15,6 +15,7 @@ import eventHub from '../event_hub';
import GroupsApp from './app.vue';
const [SORTING_ITEM_NAME] = OVERVIEW_TABS_SORTING_ITEMS;
+const MIN_SEARCH_LENGTH = 3;
export default {
components: { GlTabs, GlTab, GroupsApp, GlSearchBoxByType, GlSorting, GlSortingItem },
@@ -136,7 +137,9 @@ export default {
handleSearchInput(value) {
this.search = value;
- this.debouncedSearch();
+ if (!this.search || this.search.length >= MIN_SEARCH_LENGTH) {
+ this.debouncedSearch();
+ }
},
debouncedSearch: debounce(async function debouncedSearch() {
this.handleSearchOrSortChange();
diff --git a/app/assets/javascripts/groups/components/transfer_group_form.vue b/app/assets/javascripts/groups/components/transfer_group_form.vue
index e28459811d7..15a193f7cb8 100644
--- a/app/assets/javascripts/groups/components/transfer_group_form.vue
+++ b/app/assets/javascripts/groups/components/transfer_group_form.vue
@@ -1,29 +1,24 @@
<script>
-import { GlFormGroup } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue';
-import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select_deprecated.vue';
+import TransferLocations from '~/groups_projects/components/transfer_locations.vue';
+import { getGroupTransferLocations } from '~/api/groups_api';
export const i18n = {
confirmationMessage: __(
'You are going to transfer %{group_name} to another namespace. Are you ABSOLUTELY sure?',
),
emptyNamespaceTitle: __('No parent group'),
- dropdownTitle: s__('GroupSettings|Select parent group'),
+ dropdownLabel: s__('GroupSettings|Select parent group'),
};
export default {
name: 'TransferGroupForm',
components: {
ConfirmDanger,
- GlFormGroup,
- NamespaceSelect,
+ TransferLocations,
},
props: {
- groupNamespaces: {
- type: Array,
- required: true,
- },
isPaidGroup: {
type: Boolean,
required: true,
@@ -39,36 +34,41 @@ export default {
},
data() {
return {
- selectedId: null,
+ selectedTransferLocation: null,
};
},
computed: {
disableSubmitButton() {
- return this.isPaidGroup || !this.selectedId;
+ return this.isPaidGroup || !this.selectedTransferLocation;
+ },
+ selectedTransferLocationId() {
+ return this.selectedTransferLocation?.id;
},
},
methods: {
- handleSelected({ id }) {
- this.selectedId = id;
- },
+ getGroupTransferLocations,
},
i18n,
+ additionalDropdownItems: [
+ {
+ id: -1,
+ humanName: i18n.emptyNamespaceTitle,
+ },
+ ],
};
</script>
<template>
<div>
- <gl-form-group v-if="!isPaidGroup">
- <namespace-select
- :default-text="$options.i18n.dropdownTitle"
- :group-namespaces="groupNamespaces"
- :empty-namespace-title="$options.i18n.emptyNamespaceTitle"
- :include-headers="false"
- include-empty-namespace
- data-testid="transfer-group-namespace-select"
- @select="handleSelected"
- />
- <input type="hidden" name="new_parent_group_id" :value="selectedId" />
- </gl-form-group>
+ <input type="hidden" name="new_parent_group_id" :value="selectedTransferLocationId" />
+ <transfer-locations
+ v-if="!isPaidGroup"
+ v-model="selectedTransferLocation"
+ :show-user-transfer-locations="false"
+ data-testid="transfer-group-namespace"
+ :group-transfer-locations-api-method="getGroupTransferLocations"
+ :additional-dropdown-items="$options.additionalDropdownItems"
+ :label="$options.i18n.dropdownLabel"
+ />
<confirm-danger
:disabled="disableSubmitButton"
:phrase="confirmationPhrase"
diff --git a/app/assets/javascripts/groups/init_transfer_group_form.js b/app/assets/javascripts/groups/init_transfer_group_form.js
index f055b926918..503dad673dd 100644
--- a/app/assets/javascripts/groups/init_transfer_group_form.js
+++ b/app/assets/javascripts/groups/init_transfer_group_form.js
@@ -1,42 +1,38 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import { sprintf } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils';
import TransferGroupForm, { i18n } from './components/transfer_group_form.vue';
-const prepareGroups = (rawGroups) => {
- if (!rawGroups) {
- return [];
- }
-
- return JSON.parse(rawGroups).map(({ id, text: humanName }) => ({
- id,
- humanName,
- }));
-};
-
export default () => {
const el = document.querySelector('.js-transfer-group-form');
if (!el) {
return false;
}
+ Vue.use(VueApollo);
+
const {
targetFormId = null,
buttonText: confirmButtonText = '',
groupName = '',
- parentGroups,
+ groupId: resourceId,
isPaidGroup,
} = el.dataset;
return new Vue({
el,
+ apolloProvider: new VueApollo({
+ defaultClient: createDefaultClient(),
+ }),
provide: {
confirmDangerMessage: sprintf(i18n.confirmationMessage, { group_name: groupName }),
+ resourceId,
},
render(createElement) {
return createElement(TransferGroupForm, {
props: {
- groupNamespaces: prepareGroups(parentGroups),
isPaidGroup: parseBoolean(isPaidGroup),
confirmButtonText,
confirmationPhrase: groupName,
diff --git a/app/assets/javascripts/groups_projects/components/transfer_locations.vue b/app/assets/javascripts/groups_projects/components/transfer_locations.vue
new file mode 100644
index 00000000000..e0c8ce36e3c
--- /dev/null
+++ b/app/assets/javascripts/groups_projects/components/transfer_locations.vue
@@ -0,0 +1,282 @@
+<script>
+import {
+ GlAlert,
+ GlFormGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ GlIntersectionObserver,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+import { s__, __ } from '~/locale';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import currentUserNamespace from '~/projects/settings/graphql/queries/current_user_namespace.query.graphql';
+
+export const i18n = {
+ SELECT_A_NAMESPACE: __('Select a new namespace'),
+ GROUPS: __('Groups'),
+ USERS: __('Users'),
+ ERROR_MESSAGE: s__(
+ 'ProjectTransfer|An error occurred fetching the transfer locations, please refresh the page and try again.',
+ ),
+ ALERT_DISMISS_LABEL: __('Dismiss'),
+};
+
+export default {
+ name: 'TransferLocations',
+ components: {
+ GlAlert,
+ GlFormGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ GlIntersectionObserver,
+ GlLoadingIcon,
+ },
+ inject: ['resourceId'],
+ props: {
+ value: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ groupTransferLocationsApiMethod: {
+ type: Function,
+ required: true,
+ },
+ showUserTransferLocations: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ additionalDropdownItems: {
+ type: Array,
+ required: false,
+ default() {
+ return [];
+ },
+ },
+ label: {
+ type: String,
+ required: false,
+ default: i18n.SELECT_A_NAMESPACE,
+ },
+ },
+ initialTransferLocationsLoaded: false,
+ data() {
+ return {
+ searchTerm: '',
+ userTransferLocations: [],
+ groupTransferLocations: [],
+ filteredAdditionalDropdownItems: this.additionalDropdownItems,
+ isLoading: false,
+ isSearchLoading: false,
+ hasError: false,
+ page: 1,
+ totalPages: 1,
+ };
+ },
+ computed: {
+ hasUserTransferLocations() {
+ return this.userTransferLocations.length;
+ },
+ hasGroupTransferLocations() {
+ return this.groupTransferLocations.length;
+ },
+ selectedText() {
+ return this.value?.humanName || this.label;
+ },
+ hasNextPageOfGroups() {
+ return this.page < this.totalPages;
+ },
+ showAdditionalDropdownItems() {
+ return !this.isLoading && this.filteredAdditionalDropdownItems.length;
+ },
+ },
+ watch: {
+ searchTerm() {
+ this.page = 1;
+
+ this.debouncedSearch();
+ },
+ },
+ methods: {
+ handleSelect(item) {
+ this.searchTerm = '';
+ this.$emit('input', item);
+ },
+ async handleShow() {
+ if (this.$options.initialTransferLocationsLoaded) {
+ return;
+ }
+
+ this.isLoading = true;
+
+ [this.groupTransferLocations, this.userTransferLocations] = await Promise.all([
+ this.getGroupTransferLocations(),
+ this.getUserTransferLocations(),
+ ]);
+
+ this.isLoading = false;
+ this.$options.initialTransferLocationsLoaded = true;
+ },
+ async getGroupTransferLocations() {
+ try {
+ const {
+ data: groupTransferLocations,
+ headers,
+ } = await this.groupTransferLocationsApiMethod(this.resourceId, {
+ page: this.page,
+ search: this.searchTerm,
+ });
+
+ const { totalPages } = parseIntPagination(normalizeHeaders(headers));
+ this.totalPages = totalPages;
+
+ return groupTransferLocations.map(({ id, full_name: humanName }) => ({
+ id,
+ humanName,
+ }));
+ } catch {
+ this.handleError();
+
+ return [];
+ }
+ },
+ async getUserTransferLocations() {
+ if (!this.showUserTransferLocations) {
+ return [];
+ }
+
+ try {
+ const {
+ data: {
+ currentUser: { namespace },
+ },
+ } = await this.$apollo.query({
+ query: currentUserNamespace,
+ });
+
+ if (!namespace) {
+ return [];
+ }
+
+ return [
+ {
+ id: getIdFromGraphQLId(namespace.id),
+ humanName: namespace.fullName,
+ },
+ ];
+ } catch {
+ this.handleError();
+
+ return [];
+ }
+ },
+ async handleLoadMoreGroups() {
+ this.isLoading = true;
+ this.page += 1;
+
+ const groupTransferLocations = await this.getGroupTransferLocations();
+ this.groupTransferLocations.push(...groupTransferLocations);
+
+ this.isLoading = false;
+ },
+ debouncedSearch: debounce(async function debouncedSearch() {
+ this.isSearchLoading = true;
+
+ this.groupTransferLocations = await this.getGroupTransferLocations();
+
+ this.filteredAdditionalDropdownItems = this.additionalDropdownItems.filter((dropdownItem) =>
+ dropdownItem.humanName.toLowerCase().includes(this.searchTerm.toLowerCase()),
+ );
+
+ this.isSearchLoading = false;
+ }, DEBOUNCE_DELAY),
+ handleError() {
+ this.hasError = true;
+ },
+ handleAlertDismiss() {
+ this.hasError = false;
+ },
+ },
+ i18n,
+};
+</script>
+<template>
+ <div>
+ <gl-alert
+ v-if="hasError"
+ variant="danger"
+ :dismiss-label="$options.i18n.ALERT_DISMISS_LABEL"
+ @dismiss="handleAlertDismiss"
+ >{{ $options.i18n.ERROR_MESSAGE }}</gl-alert
+ >
+ <gl-form-group :label="label">
+ <gl-dropdown
+ :text="selectedText"
+ data-qa-selector="namespaces_list"
+ data-testid="transfer-locations-dropdown"
+ block
+ toggle-class="gl-mb-0"
+ @show="handleShow"
+ >
+ <template #header>
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ :is-loading="isSearchLoading"
+ data-qa-selector="namespaces_list_search"
+ />
+ </template>
+ <template v-if="showAdditionalDropdownItems">
+ <gl-dropdown-item
+ v-for="item in filteredAdditionalDropdownItems"
+ :key="item.id"
+ @click="handleSelect(item)"
+ >{{ item.humanName }}</gl-dropdown-item
+ >
+ <gl-dropdown-divider />
+ </template>
+ <div
+ v-if="hasUserTransferLocations"
+ data-qa-selector="namespaces_list_users"
+ data-testid="user-transfer-locations"
+ >
+ <gl-dropdown-section-header>{{ $options.i18n.USERS }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="item in userTransferLocations"
+ :key="item.id"
+ data-qa-selector="namespaces_list_item"
+ @click="handleSelect(item)"
+ >{{ item.humanName }}</gl-dropdown-item
+ >
+ </div>
+ <div
+ v-if="hasGroupTransferLocations"
+ data-qa-selector="namespaces_list_groups"
+ data-testid="group-transfer-locations"
+ >
+ <gl-dropdown-section-header v-if="showUserTransferLocations">{{
+ $options.i18n.GROUPS
+ }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="item in groupTransferLocations"
+ :key="item.id"
+ data-qa-selector="namespaces_list_item"
+ @click="handleSelect(item)"
+ >{{ item.humanName }}</gl-dropdown-item
+ >
+ </div>
+ <gl-loading-icon v-if="isLoading" class="gl-mb-3" size="sm" />
+ <gl-intersection-observer v-if="hasNextPageOfGroups" @appear="handleLoadMoreGroups" />
+ </gl-dropdown>
+ </gl-form-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
index 34e984a9bb9..fb0c47fe018 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -1,11 +1,38 @@
+import Vue from 'vue';
import $ from 'jquery';
import { escape } from 'lodash';
+import GroupSelect from '~/vue_shared/components/group_select/group_select.vue';
import { groupsPath } from '~/vue_shared/components/group_select/utils';
import { __ } from '~/locale';
import Api from './api';
import { loadCSSFile } from './lib/utils/css_utils';
import { select2AxiosTransport } from './lib/utils/select2_utils';
+const initVueSelect = () => {
+ [...document.querySelectorAll('.ajax-groups-select')].forEach((el) => {
+ const { parentId: parentGroupID, groupsFilter, inputId } = el.dataset;
+
+ return new Vue({
+ el,
+ components: {
+ GroupSelect,
+ },
+ render(createElement) {
+ return createElement(GroupSelect, {
+ props: {
+ inputName: el.name,
+ initialSelection: el.value || null,
+ parentGroupID,
+ groupsFilter,
+ inputId,
+ clearable: el.classList.contains('allowClear'),
+ },
+ });
+ },
+ });
+ });
+};
+
const groupsSelect = () => {
loadCSSFile(gon.select2_css_path)
.then(() => {
@@ -84,8 +111,12 @@ const groupsSelect = () => {
export default () => {
if ($('.ajax-groups-select').length) {
- import(/* webpackChunkName: 'select2' */ 'select2/select2')
- .then(groupsSelect)
- .catch(() => {});
+ if (gon.features?.vueGroupSelect) {
+ initVueSelect();
+ } else {
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(groupsSelect)
+ .catch(() => {});
+ }
}
};
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index d589f56dd7c..838debf1ceb 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -47,6 +47,7 @@ export default {
data() {
return {
loadDeferred: false,
+ skipBeforeUnload: false,
};
},
computed: {
@@ -78,9 +79,14 @@ export default {
mounted() {
window.onbeforeunload = (e) => this.onBeforeUnload(e);
+ eventHub.$on('skip-beforeunload', this.handleSkipBeforeUnload);
+
if (this.themeName)
document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`);
},
+ destroyed() {
+ eventHub.$off('skip-beforeunload', this.handleSkipBeforeUnload);
+ },
beforeCreate() {
performanceMarkAndMeasure({
mark: WEBIDE_MARK_APP_START,
@@ -94,6 +100,11 @@ export default {
methods: {
...mapActions(['toggleFileFinder']),
onBeforeUnload(e = {}) {
+ if (this.skipBeforeUnload) {
+ this.skipBeforeUnload = false;
+ return undefined;
+ }
+
const returnValue = __('Are you sure you want to lose unsaved changes?');
if (!this.someUncommittedChanges) return undefined;
@@ -103,6 +114,9 @@ export default {
});
return returnValue;
},
+ handleSkipBeforeUnload() {
+ this.skipBeforeUnload = true;
+ },
openFile(file) {
this.$router.push(this.getUrlForPath(file.path));
},
diff --git a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
index 6f42ae48cc9..bf99538a2ad 100644
--- a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
+++ b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
@@ -13,6 +13,11 @@ export default {
required: false,
default: () => [],
},
+ initOpenView: {
+ type: String,
+ required: false,
+ default: '',
+ },
side: {
type: String,
required: true,
@@ -44,6 +49,9 @@ export default {
return this.tabViews.filter((view) => this.isAliveView(view.name));
},
},
+ created() {
+ this.openViewByName(this.initOpenView);
+ },
methods: {
...mapActions({
toggleOpen(dispatch) {
@@ -53,6 +61,13 @@ export default {
return dispatch(`${this.namespace}/open`, view);
},
}),
+ openViewByName(viewName) {
+ const view = viewName && this.tabViews.find((x) => x.name === viewName);
+
+ if (view) {
+ this.open(view);
+ }
+ },
},
};
</script>
diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue
index da2d4fbe7f0..c74a5052573 100644
--- a/app/assets/javascripts/ide/components/panes/right.vue
+++ b/app/assets/javascripts/ide/components/panes/right.vue
@@ -7,6 +7,7 @@ import PipelinesList from '../pipelines/list.vue';
import Clientside from '../preview/clientside.vue';
import ResizablePanel from '../resizable_panel.vue';
import TerminalView from '../terminal/view.vue';
+import SwitchEditorsView from '../switch_editors/switch_editors_view.vue';
import CollapsibleSidebar from './collapsible_sidebar.vue';
// Need to add the width of the nav buttons since the resizable container contains those as well
@@ -20,7 +21,7 @@ export default {
},
computed: {
...mapState('terminal', { isTerminalVisible: 'isVisible' }),
- ...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled']),
+ ...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled', 'canUseNewWebIde']),
...mapGetters(['packageJson']),
...mapState('rightPane', ['isOpen']),
showLivePreview() {
@@ -29,6 +30,12 @@ export default {
rightExtensionTabs() {
return [
{
+ show: this.canUseNewWebIde,
+ title: __('Switch editors'),
+ views: [{ component: SwitchEditorsView, ...rightSidebarViews.switchEditors }],
+ icon: 'bullhorn',
+ },
+ {
show: true,
title: __('Pipelines'),
views: [
@@ -53,6 +60,7 @@ export default {
},
},
WIDTH,
+ SWITCH_EDITORS_VIEW_NAME: rightSidebarViews.switchEditors.name,
};
</script>
@@ -64,6 +72,11 @@ export default {
:min-size="$options.WIDTH"
:resizable="isOpen"
>
- <collapsible-sidebar class="gl-w-full" :extension-tabs="rightExtensionTabs" side="right" />
+ <collapsible-sidebar
+ class="gl-w-full"
+ :extension-tabs="rightExtensionTabs"
+ :init-open-view="$options.SWITCH_EDITORS_VIEW_NAME"
+ side="right"
+ />
</resizable-panel>
</template>
diff --git a/app/assets/javascripts/ide/components/switch_editors/switch_editors_view.vue b/app/assets/javascripts/ide/components/switch_editors/switch_editors_view.vue
new file mode 100644
index 00000000000..00164f65e33
--- /dev/null
+++ b/app/assets/javascripts/ide/components/switch_editors/switch_editors_view.vue
@@ -0,0 +1,103 @@
+<script>
+import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui';
+import { mapState } from 'vuex';
+import { createAlert } from '~/flash';
+import { logError } from '~/lib/logger';
+import axios from '~/lib/utils/axios_utils';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
+import { s__, __ } from '~/locale';
+import eventHub from '../../eventhub';
+
+export const MSG_DESCRIPTION = s__('WebIDE|You are invited to experience the new Web IDE.');
+export const MSG_BUTTON_TEXT = s__('WebIDE|Switch to new Web IDE');
+export const MSG_LEARN_MORE = __('Learn more');
+export const MSG_TITLE = s__('WebIDE|Ready for something new?');
+
+export const MSG_CONFIRM = s__(
+ 'WebIDE|Are you sure you want to switch editors? You will lose any unsaved changes.',
+);
+export const MSG_ERROR_ALERT = s__(
+ 'WebIDE|Something went wrong while updating the user preferences. Please see developer console for details.',
+);
+
+export default {
+ components: {
+ GlButton,
+ GlEmptyState,
+ GlLink,
+ },
+ data() {
+ return {
+ loading: false,
+ };
+ },
+ computed: {
+ ...mapState(['switchEditorSvgPath', 'links', 'userPreferencesPath']),
+ },
+ methods: {
+ async submitSwitch() {
+ const confirmed = await confirmAction(MSG_CONFIRM, {
+ primaryBtnText: __('Switch editors'),
+ cancelBtnText: __('Cancel'),
+ });
+
+ if (!confirmed) {
+ return;
+ }
+
+ try {
+ await axios.put(this.userPreferencesPath, {
+ user: { use_legacy_web_ide: false },
+ });
+ } catch (e) {
+ // why: We do not want to translate console logs
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ logError('Error while updating user preferences', e);
+ createAlert({
+ message: MSG_ERROR_ALERT,
+ });
+ return;
+ }
+
+ eventHub.$emit('skip-beforeunload');
+ window.location.reload();
+ },
+ // what: ignoreWhilePending prevents double confirmation boxes
+ onSwitchClicked: ignoreWhilePending(async function onSwitchClicked() {
+ this.loading = true;
+
+ try {
+ await this.submitSwitch();
+ } finally {
+ this.loading = false;
+ }
+ }),
+ },
+ MSG_TITLE,
+ MSG_DESCRIPTION,
+ MSG_BUTTON_TEXT,
+ MSG_LEARN_MORE,
+};
+</script>
+
+<template>
+ <div class="gl-h-full gl-display-flex gl-flex-direction-column gl-justify-content-center">
+ <gl-empty-state :svg-path="switchEditorSvgPath" :svg-height="150" :title="$options.MSG_TITLE">
+ <template #description>
+ <span>{{ $options.MSG_DESCRIPTION }}</span>
+ <gl-link :href="links.newWebIDEHelpPagePath">{{ $options.MSG_LEARN_MORE }}</gl-link
+ >.
+ </template>
+ <template #actions>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :loading="loading"
+ @click="onSwitchClicked"
+ >{{ $options.MSG_BUTTON_TEXT }}</gl-button
+ >
+ </template>
+ </gl-empty-state>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index bfe4c3ac271..c8e737fa6f5 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -61,6 +61,7 @@ export const leftSidebarViews = {
};
export const rightSidebarViews = {
+ switchEditors: { name: 'switch-editors', keepAlive: true },
pipelines: { name: 'pipelines-list', keepAlive: true },
jobsDetail: { name: 'jobs-detail', keepAlive: false },
mergeRequestInfo: { name: 'merge-request-info', keepAlive: true },
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index 1a191f6f76f..dec282239d9 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -60,9 +60,11 @@ export const initLegacyWebIDE = (el, options = {}) => {
committedStateSvgPath: el.dataset.committedStateSvgPath,
pipelinesEmptyStateSvgPath: el.dataset.pipelinesEmptyStateSvgPath,
promotionSvgPath: el.dataset.promotionSvgPath,
+ switchEditorSvgPath: el.dataset.switchEditorSvgPath,
});
this.setLinks({
webIDEHelpPagePath: el.dataset.webIdeHelpPagePath,
+ newWebIDEHelpPagePath: el.dataset.newWebIdeHelpPagePath,
forkInfo: el.dataset.forkInfo ? JSON.parse(el.dataset.forkInfo) : null,
});
this.init({
@@ -72,6 +74,8 @@ export const initLegacyWebIDE = (el, options = {}) => {
codesandboxBundlerUrl: el.dataset.codesandboxBundlerUrl,
environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance),
previewMarkdownPath: el.dataset.previewMarkdownPath,
+ canUseNewWebIde: parseBoolean(el.dataset.canUseNewWebIde),
+ userPreferencesPath: el.dataset.userPreferencesPath,
});
},
beforeDestroy() {
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index 48648796e66..d11fc388d5e 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -110,6 +110,7 @@ export default {
committedStateSvgPath,
pipelinesEmptyStateSvgPath,
promotionSvgPath,
+ switchEditorSvgPath,
},
) {
Object.assign(state, {
@@ -118,6 +119,7 @@ export default {
committedStateSvgPath,
pipelinesEmptyStateSvgPath,
promotionSvgPath,
+ switchEditorSvgPath,
});
},
[types.TOGGLE_FILE_FINDER](state, fileFindVisible) {
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index 526987c750a..70efda970bf 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -33,4 +33,6 @@ export default () => ({
environmentsGuidanceAlertDismissed: false,
environmentsGuidanceAlertDetected: false,
previewMarkdownPath: '',
+ userPreferencesPath: '',
+ canUseNewWebIde: false,
});
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 0cdd64b1b98..66dff77eef8 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
@@ -10,6 +10,7 @@ import {
GlSprintf,
GlTable,
GlFormCheckbox,
+ GlTooltipDirective,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { createAlert } from '~/flash';
@@ -60,7 +61,9 @@ export default {
PaginationBar,
HelpPopover,
},
-
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
sourceUrl: {
type: String,
@@ -118,14 +121,14 @@ export default {
},
{
key: 'webUrl',
- label: s__('BulkImport|From source group'),
+ label: s__('BulkImport|Source group'),
thClass: `${DEFAULT_TH_CLASSES} gl-pl-0! import-jobs-from-col`,
// eslint-disable-next-line @gitlab/require-i18n-strings
tdClass: `${DEFAULT_TD_CLASSES} gl-pl-0!`,
},
{
key: 'importTarget',
- label: s__('BulkImport|To new group'),
+ label: s__('BulkImport|New group'),
thClass: `${DEFAULT_TH_CLASSES} import-jobs-to-col`,
tdClass: DEFAULT_TD_CLASSES,
},
@@ -665,6 +668,16 @@ export default {
@change="hasAllAvailableGroupsSelected ? clearSelected() : selectAllRows()"
/>
</template>
+ <template #head(importTarget)="data">
+ <span data-test-id="new-path-col">
+ <span class="gl-mr-2">{{ data.label }}</span
+ ><gl-icon
+ v-gl-tooltip="s__('BulkImport|Path of the new group.')"
+ name="information"
+ :size="12"
+ />
+ </span>
+ </template>
<template #cell(selected)="{ rowSelected, selectRow, unselectRow, item: group }">
<gl-form-checkbox
class="gl-h-7 gl-pt-3"
diff --git a/app/assets/javascripts/import_entities/import_projects/components/advanced_settings.vue b/app/assets/javascripts/import_entities/import_projects/components/advanced_settings.vue
index a8fdf9b9ec5..cf1a4de68ed 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/advanced_settings.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/advanced_settings.vue
@@ -40,6 +40,8 @@ export default {
v-for="{ name, label, details } in stages"
:key="name"
:checked="value[name]"
+ :data-qa-option-name="name"
+ data-qa-selector="advanced_settings_checkbox"
@change="$emit('input', { ...value, [name]: $event })"
>
{{ label }}
diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js
index 4daa9e8a1b8..df26d6ac4f6 100644
--- a/app/assets/javascripts/import_entities/import_projects/index.js
+++ b/app/assets/javascripts/import_entities/import_projects/index.js
@@ -39,7 +39,7 @@ export function initStoreFromElement(element) {
export function initPropsFromElement(element) {
return {
- providerTitle: element.dataset.provider,
+ providerTitle: element.dataset.providerTitle,
filterable: parseBoolean(element.dataset.filterable),
paginatable: parseBoolean(element.dataset.paginatable),
optionalStages: JSON.parse(element.dataset.optionalStages),
diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js
index 2806b785816..392dd63b089 100644
--- a/app/assets/javascripts/integrations/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -85,10 +85,12 @@ export const billingPlanNames = {
};
const INTEGRATION_TYPE_SLACK = 'slack';
+const INTEGRATION_TYPE_SLACK_APPLICATION = 'gitlab_slack_application';
const INTEGRATION_TYPE_MATTERMOST = 'mattermost';
export const placeholderForType = {
[INTEGRATION_TYPE_SLACK]: __('#general, #development'),
+ [INTEGRATION_TYPE_SLACK_APPLICATION]: __('#general, #development'),
[INTEGRATION_TYPE_MATTERMOST]: __('my-channel'),
};
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index 15f76c16516..4bf2b8d4468 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -1,5 +1,6 @@
<script>
import {
+ GlAlert,
GlBadge,
GlButton,
GlModalDirective,
@@ -9,7 +10,7 @@ import {
import axios from 'axios';
import * as Sentry from '@sentry/browser';
import { mapState, mapActions, mapGetters } from 'vuex';
-
+import { s__ } from '~/locale';
import {
I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE,
I18N_DEFAULT_ERROR_MESSAGE,
@@ -59,6 +60,7 @@ export default {
import(
/* webpackChunkName: 'integrationSectionTrigger' */ '~/integrations/edit/components/sections/trigger.vue'
),
+ GlAlert,
GlBadge,
GlButton,
GlForm,
@@ -223,6 +225,12 @@ export default {
csrf,
integrationFormSectionComponents,
billingPlanNames,
+ slackUpgradeInfo: {
+ title: s__(
+ `SlackIntegration|Notifications only work if you're on the latest version of the GitLab for Slack app`,
+ ),
+ btnText: s__('SlackIntegration|Update to the latest version'),
+ },
};
</script>
@@ -277,6 +285,15 @@ export default {
</section>
<template v-if="hasSections">
+ <div v-if="customState.shouldUpgradeSlack && isSlackIntegration" class="gl-border-t">
+ <gl-alert
+ :title="$options.slackUpgradeInfo.title"
+ variant="warning"
+ :primary-button-link="customState.upgradeSlackUrl"
+ :primary-button-text="$options.slackUpgradeInfo.btnText"
+ class="gl-mb-8 gl-mt-5"
+ />
+ </div>
<div
v-for="(section, index) in customState.sections"
:key="section.type"
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 403bad3db11..41cd650f932 100644
--- a/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue
+++ b/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue
@@ -7,18 +7,12 @@ export default {
components: {
GlModal,
},
- computed: {
- primaryProps() {
- return {
- text: __('Reset'),
- attributes: [{ variant: 'danger' }, { category: 'primary' }],
- };
- },
- cancelProps() {
- return {
- text: __('Cancel'),
- };
- },
+ primaryProps: {
+ text: __('Reset'),
+ attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ },
+ cancelProps: {
+ text: __('Cancel'),
},
methods: {
onReset() {
@@ -33,8 +27,8 @@ export default {
modal-id="confirmResetIntegration"
size="sm"
:title="s__('Integrations|Reset integration?')"
- :action-primary="primaryProps"
- :action-cancel="cancelProps"
+ :action-primary="$options.primaryProps"
+ :action-cancel="$options.cancelProps"
@primary="onReset"
>
<p>
diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
index 67647cadf19..3820a87e5ad 100644
--- a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
@@ -23,7 +23,7 @@ export default {
},
computed: {
...mapGetters(['isInheriting']),
- placeholder() {
+ defaultPlaceholder() {
return placeholderForType[this.type];
},
},
@@ -55,7 +55,7 @@ export default {
v-if="event.field"
v-model="event.field.value"
:name="fieldName(event.field.name)"
- :placeholder="placeholder"
+ :placeholder="event.field.placeholder || defaultPlaceholder"
:readonly="isInheriting"
/>
</gl-form-group>
diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js
index 2360588ab39..f15ad5e052e 100644
--- a/app/assets/javascripts/integrations/edit/index.js
+++ b/app/assets/javascripts/integrations/edit/index.js
@@ -36,6 +36,7 @@ function parseDatasetToProps(data) {
jiraIssueTransitionAutomatic,
jiraIssueTransitionId,
redirectTo,
+ upgradeSlackUrl,
...booleanAttributes
} = data;
const {
@@ -51,6 +52,7 @@ function parseDatasetToProps(data) {
showJiraVulnerabilitiesIntegration,
enableJiraIssues,
enableJiraVulnerabilities,
+ shouldUpgradeSlack,
} = parseBooleanInData(booleanAttributes);
return {
@@ -89,6 +91,8 @@ function parseDatasetToProps(data) {
integrationLevel,
id: parseInt(id, 10),
redirectTo,
+ shouldUpgradeSlack,
+ upgradeSlackUrl,
};
}
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 a334f5e4bf7..f61e822bf7e 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -18,7 +18,8 @@ import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { getParameterValues } from '~/lib/utils/url_utility';
import { n__, sprintf } from '~/locale';
import {
- CLOSE_TO_LIMIT_COUNT,
+ CLOSE_TO_LIMIT_VARIANT,
+ REACHED_LIMIT_VARIANT,
USERS_FILTER_ALL,
INVITE_MEMBERS_FOR_TASK,
MEMBER_MODAL_LABELS,
@@ -174,27 +175,11 @@ export default {
isOnLearnGitlab() {
return this.source === LEARN_GITLAB;
},
- closeToLimit() {
- if (this.usersLimitDataset.freeUsersLimit && this.usersLimitDataset.membersCount) {
- return (
- this.usersLimitDataset.membersCount >=
- this.usersLimitDataset.freeUsersLimit - CLOSE_TO_LIMIT_COUNT
- );
- }
-
- return false;
- },
- reachedLimit() {
- if (this.usersLimitDataset.freeUsersLimit && this.usersLimitDataset.membersCount) {
- return this.usersLimitDataset.membersCount >= this.usersLimitDataset.freeUsersLimit;
- }
-
- return false;
+ showUserLimitNotification() {
+ return this.usersLimitDataset.reachedLimit || this.usersLimitDataset.closeToDashboardLimit;
},
- formGroupDescription() {
- return this.reachedLimit
- ? this.$options.labels.placeHolderDisabled
- : this.$options.labels.placeHolder;
+ limitVariant() {
+ return this.usersLimitDataset.reachedLimit ? REACHED_LIMIT_VARIANT : CLOSE_TO_LIMIT_VARIANT;
},
errorList() {
return Object.entries(this.invalidMembers).map(([member, error]) => {
@@ -385,13 +370,11 @@ export default {
:help-link="helpLink"
:label-intro-text="labelIntroText"
:label-search-field="$options.labels.searchField"
- :form-group-description="formGroupDescription"
+ :form-group-description="$options.labels.placeHolder"
:invalid-feedback-message="invalidFeedbackMessage"
:is-loading="isLoading"
:new-users-to-invite="newUsersToInvite"
:root-group-id="rootId"
- :close-to-limit="closeToLimit"
- :reached-limit="reachedLimit"
:users-limit-dataset="usersLimitDataset"
@reset="resetFields"
@submit="sendInvite"
@@ -448,9 +431,8 @@ export default {
</template>
</gl-alert>
<user-limit-notification
- v-else
- :close-to-limit="closeToLimit"
- :reached-limit="reachedLimit"
+ v-else-if="showUserLimitNotification"
+ :limit-variant="limitVariant"
:users-limit-dataset="usersLimitDataset"
/>
</template>
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 f917ebc35c2..e3511a49fc5 100644
--- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue
+++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
@@ -8,7 +8,6 @@ import {
GlLink,
GlSprintf,
GlFormInput,
- GlIcon,
} from '@gitlab/ui';
import Tracking from '~/tracking';
import { sprintf } from '~/locale';
@@ -20,11 +19,8 @@ import {
INVITE_BUTTON_TEXT,
INVITE_BUTTON_TEXT_DISABLED,
CANCEL_BUTTON_TEXT,
- CANCEL_BUTTON_TEXT_DISABLED,
HEADER_CLOSE_LABEL,
ON_SHOW_TRACK_LABEL,
- ON_CLOSE_TRACK_LABEL,
- ON_SUBMIT_TRACK_LABEL,
} from '../constants';
const DEFAULT_SLOT = 'default';
@@ -48,7 +44,6 @@ export default {
GlDropdownItem,
GlSprintf,
GlFormInput,
- GlIcon,
ContentTransition,
},
mixins: [Tracking.mixin()],
@@ -131,16 +126,6 @@ export default {
required: false,
default: false,
},
- closeToLimit: {
- type: Boolean,
- required: false,
- default: false,
- },
- reachedLimit: {
- type: Boolean,
- required: false,
- default: false,
- },
usersLimitDataset: {
type: Object,
required: false,
@@ -175,20 +160,17 @@ export default {
},
actionPrimary() {
return {
- text: this.reachedLimit ? INVITE_BUTTON_TEXT_DISABLED : this.submitButtonText,
+ text: this.submitButtonText,
attributes: {
variant: 'confirm',
- disabled: this.reachedLimit ? false : this.submitDisabled,
- loading: this.reachedLimit ? false : this.isLoading,
+ disabled: this.submitDisabled,
+ loading: this.isLoading,
'data-qa-selector': 'invite_button',
- ...(this.reachedLimit && { href: this.usersLimitDataset.membersPath }),
},
};
},
actionCancel() {
- if (this.reachedLimit && this.usersLimitDataset.userNamespace) return undefined;
-
- if (this.closeToLimit && this.usersLimitDataset.userNamespace) {
+ if (this.usersLimitDataset.closeToDashboardLimit && this.usersLimitDataset.userNamespace) {
return {
text: INVITE_BUTTON_TEXT_DISABLED,
attributes: {
@@ -200,13 +182,9 @@ export default {
}
return {
- text: this.reachedLimit ? CANCEL_BUTTON_TEXT_DISABLED : this.cancelButtonText,
- ...(this.reachedLimit && { attributes: { href: this.usersLimitDataset.purchasePath } }),
+ text: this.cancelButtonText,
};
},
- selectLabelClass() {
- return `col-form-label ${this.reachedLimit ? 'gl-text-gray-500' : ''}`;
- },
},
watch: {
selectedAccessLevel: {
@@ -226,23 +204,19 @@ export default {
this.$emit('reset');
},
onShowModal() {
- if (this.reachedLimit) {
+ if (this.usersLimitDataset.reachedLimit) {
this.track('render', { category: 'default', label: ON_SHOW_TRACK_LABEL });
}
},
onCloseModal(e) {
- if (this.preventCancelDefault || this.reachedLimit) {
+ if (this.preventCancelDefault) {
e.preventDefault();
} else {
this.onReset();
this.$refs.modal.hide();
}
- if (this.reachedLimit) {
- this.track('click_button', { category: 'default', label: ON_CLOSE_TRACK_LABEL });
- } else {
- this.$emit('cancel');
- }
+ this.$emit('cancel');
},
changeSelectedItem(item) {
this.selectedAccessLevel = item;
@@ -251,14 +225,10 @@ export default {
// We never want to hide when submitting
e.preventDefault();
- if (this.reachedLimit) {
- this.track('click_button', { category: 'default', label: ON_SUBMIT_TRACK_LABEL });
- } else {
- this.$emit('submit', {
- accessLevel: this.selectedAccessLevel,
- expiresAt: this.selectedDate,
- });
- }
+ this.$emit('submit', {
+ accessLevel: this.selectedAccessLevel,
+ expiresAt: this.selectedDate,
+ });
},
},
HEADER_CLOSE_LABEL,
@@ -311,71 +281,63 @@ export default {
<gl-form-group
:invalid-feedback="invalidFeedbackMessage"
:state="exceptionState"
+ :description="formGroupDescription"
data-testid="members-form-group"
>
- <template #description>
- <gl-icon v-if="reachedLimit" name="lock" />
- {{ formGroupDescription }}
- </template>
-
- <label :id="selectLabelId" :class="selectLabelClass">{{ labelSearchField }}</label>
- <gl-form-input v-if="reachedLimit" data-testid="disabled-input" disabled />
- <slot v-else name="select" v-bind="{ exceptionState, labelId: selectLabelId }"></slot>
+ <label :id="selectLabelId" class="col-form-label">{{ labelSearchField }}</label>
+ <slot name="select" v-bind="{ exceptionState, labelId: selectLabelId }"></slot>
</gl-form-group>
- <template v-if="!reachedLimit">
- <label class="gl-font-weight-bold">{{ $options.ACCESS_LEVEL }}</label>
-
- <div class="gl-mt-2 gl-w-half gl-xs-w-full">
- <gl-dropdown
- class="gl-shadow-none gl-w-full"
- data-qa-selector="access_level_dropdown"
- v-bind="$attrs"
- :text="selectedRoleName"
- >
- <template v-for="(key, item) in accessLevels">
- <gl-dropdown-item
- :key="key"
- active-class="is-active"
- is-check-item
- :is-checked="key === selectedAccessLevel"
- @click="changeSelectedItem(key)"
- >
- <div>{{ item }}</div>
- </gl-dropdown-item>
- </template>
- </gl-dropdown>
- </div>
+ <label class="gl-font-weight-bold">{{ $options.ACCESS_LEVEL }}</label>
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full">
+ <gl-dropdown
+ class="gl-shadow-none gl-w-full"
+ data-qa-selector="access_level_dropdown"
+ v-bind="$attrs"
+ :text="selectedRoleName"
+ >
+ <template v-for="(key, item) in accessLevels">
+ <gl-dropdown-item
+ :key="key"
+ active-class="is-active"
+ is-check-item
+ :is-checked="key === selectedAccessLevel"
+ @click="changeSelectedItem(key)"
+ >
+ <div>{{ item }}</div>
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+ </div>
- <div class="gl-mt-2 gl-w-half gl-xs-w-full">
- <gl-sprintf :message="$options.READ_MORE_TEXT">
- <template #link="{ content }">
- <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </div>
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full">
+ <gl-sprintf :message="$options.READ_MORE_TEXT">
+ <template #link="{ content }">
+ <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
- <label class="gl-mt-5 gl-display-block" for="expires_at">{{
- $options.ACCESS_EXPIRE_DATE
- }}</label>
- <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block">
- <gl-datepicker
- v-model="selectedDate"
- class="gl-display-inline!"
- :min-date="minDate"
- :target="null"
- >
- <template #default="{ formattedDate }">
- <gl-form-input
- class="gl-w-full"
- :value="formattedDate"
- :placeholder="__(`YYYY-MM-DD`)"
- />
- </template>
- </gl-datepicker>
- </div>
- <slot name="form-after"></slot>
- </template>
+ <label class="gl-mt-5 gl-display-block" for="expires_at">{{
+ $options.ACCESS_EXPIRE_DATE
+ }}</label>
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block">
+ <gl-datepicker
+ v-model="selectedDate"
+ class="gl-display-inline!"
+ :min-date="minDate"
+ :target="null"
+ >
+ <template #default="{ formattedDate }">
+ <gl-form-input
+ class="gl-w-full"
+ :value="formattedDate"
+ :placeholder="__(`YYYY-MM-DD`)"
+ />
+ </template>
+ </gl-datepicker>
+ </div>
+ <slot name="form-after"></slot>
</template>
<template v-for="{ key } in extraSlots" #[key]>
diff --git a/app/assets/javascripts/invite_members/components/user_limit_notification.vue b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
index c3d9d959ef6..515dd3de319 100644
--- a/app/assets/javascripts/invite_members/components/user_limit_notification.vue
+++ b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
@@ -5,9 +5,10 @@ import { n__, sprintf } from '~/locale';
import {
WARNING_ALERT_TITLE,
DANGER_ALERT_TITLE,
- REACHED_LIMIT_MESSAGE,
REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE,
+ REACHED_LIMIT_VARIANT,
CLOSE_TO_LIMIT_MESSAGE,
+ CLOSE_TO_LIMIT_VARIANT,
} from '../constants';
export default {
@@ -15,87 +16,71 @@ export default {
components: { GlAlert, GlSprintf, GlLink },
inject: ['name'],
props: {
- closeToLimit: {
- type: Boolean,
- required: true,
- },
- reachedLimit: {
- type: Boolean,
+ limitVariant: {
+ type: String,
required: true,
},
usersLimitDataset: {
type: Object,
- required: false,
- default: () => ({}),
+ required: true,
},
},
computed: {
- freeUsersLimit() {
- return this.usersLimitDataset.freeUsersLimit;
- },
- membersCount() {
- return this.usersLimitDataset.membersCount;
- },
- newTrialRegistrationPath() {
- return this.usersLimitDataset.newTrialRegistrationPath;
- },
- purchasePath() {
- return this.usersLimitDataset.purchasePath;
- },
- warningAlertTitle() {
- return sprintf(WARNING_ALERT_TITLE, {
- count: this.freeUsersLimit - this.membersCount,
- members: this.pluralMembers(this.freeUsersLimit - this.membersCount),
- name: this.name,
- });
- },
- dangerAlertTitle() {
- return sprintf(DANGER_ALERT_TITLE, {
- count: this.freeUsersLimit,
- members: this.pluralMembers(this.freeUsersLimit),
- name: this.name,
- });
- },
- variant() {
- return this.reachedLimit ? 'danger' : 'warning';
- },
- title() {
- return this.reachedLimit ? this.dangerAlertTitle : this.warningAlertTitle;
- },
- message() {
- if (this.reachedLimit) {
- return this.$options.i18n.reachedLimitUpgradeSuggestionMessage;
- }
-
- return this.$options.i18n.closeToLimitMessage;
+ limitAttributes() {
+ return {
+ [CLOSE_TO_LIMIT_VARIANT]: {
+ variant: 'warning',
+ title: this.title(WARNING_ALERT_TITLE, this.usersLimitDataset.remainingSeats),
+ message: CLOSE_TO_LIMIT_MESSAGE,
+ },
+ [REACHED_LIMIT_VARIANT]: {
+ variant: 'danger',
+ title: this.title(DANGER_ALERT_TITLE, this.usersLimitDataset.freeUsersLimit),
+ message: REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE,
+ },
+ };
},
},
methods: {
- pluralMembers(count) {
- return n__('member', 'members', count);
+ title(titleTemplate, count) {
+ return sprintf(titleTemplate, {
+ count,
+ members: n__('member', 'members', count),
+ name: this.name,
+ });
},
},
- i18n: {
- reachedLimitMessage: REACHED_LIMIT_MESSAGE,
- reachedLimitUpgradeSuggestionMessage: REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE,
- closeToLimitMessage: CLOSE_TO_LIMIT_MESSAGE,
- },
};
</script>
<template>
<gl-alert
- v-if="reachedLimit || closeToLimit"
- :variant="variant"
+ :variant="limitAttributes[limitVariant].variant"
:dismissible="false"
- :title="title"
+ :title="limitAttributes[limitVariant].title"
>
- <gl-sprintf :message="message">
+ <gl-sprintf :message="limitAttributes[limitVariant].message">
<template #trialLink="{ content }">
- <gl-link :href="newTrialRegistrationPath" class="gl-label-link">{{ content }}</gl-link>
+ <gl-link
+ :href="usersLimitDataset.newTrialRegistrationPath"
+ class="gl-label-link"
+ data-track-action="click_link"
+ :data-track-label="`start_trial_user_limit_notification_${limitVariant}`"
+ data-testid="trial-link"
+ >
+ {{ content }}
+ </gl-link>
</template>
<template #upgradeLink="{ content }">
- <gl-link :href="purchasePath" class="gl-label-link">{{ content }}</gl-link>
+ <gl-link
+ :href="usersLimitDataset.purchasePath"
+ class="gl-label-link"
+ data-track-action="click_link"
+ :data-track-label="`upgrade_user_limit_notification_${limitVariant}`"
+ data-testid="upgrade-link"
+ >
+ {{ content }}
+ </gl-link>
</template>
</gl-sprintf>
</gl-alert>
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index f502e1ea369..de7b1019782 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -1,6 +1,5 @@
import { s__ } from '~/locale';
-export const CLOSE_TO_LIMIT_COUNT = 2;
export const SEARCH_DELAY = 200;
export const VALID_TOKEN_BACKGROUND = 'gl-bg-green-100';
export const INVALID_TOKEN_BACKGROUND = 'gl-bg-red-100';
@@ -40,9 +39,6 @@ export const MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT = s__(
);
export const MEMBERS_SEARCH_FIELD = s__('InviteMembersModal|Username or email address');
export const MEMBERS_PLACEHOLDER = s__('InviteMembersModal|Select members or type email addresses');
-export const MEMBERS_PLACEHOLDER_DISABLED = s__(
- 'InviteMembersModal|This feature is disabled until this group has space for more members.',
-);
export const MEMBERS_TASKS_TO_BE_DONE_TITLE = s__(
'InviteMembersModal|Create issues for your new team member to work on (optional)',
);
@@ -110,7 +106,6 @@ export const MEMBER_MODAL_LABELS = {
},
searchField: MEMBERS_SEARCH_FIELD,
placeHolder: MEMBERS_PLACEHOLDER,
- placeHolderDisabled: MEMBERS_PLACEHOLDER_DISABLED,
tasksToBeDone: {
title: MEMBERS_TASKS_TO_BE_DONE_TITLE,
noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS,
@@ -139,9 +134,7 @@ export const GROUP_MODAL_LABELS = {
};
export const LEARN_GITLAB = 'learn_gitlab';
-export const ON_SHOW_TRACK_LABEL = 'locked_modal_viewed';
-export const ON_CLOSE_TRACK_LABEL = 'explore_paid_plans_clicked';
-export const ON_SUBMIT_TRACK_LABEL = 'manage_members_clicked';
+export const ON_SHOW_TRACK_LABEL = 'over_limit_modal_viewed';
export const WARNING_ALERT_TITLE = s__(
'InviteMembersModal|You only have space for %{count} more %{members} in %{name}',
@@ -150,13 +143,16 @@ export const DANGER_ALERT_TITLE = s__(
"InviteMembersModal|You've reached your %{count} %{members} limit for %{name}",
);
+export const REACHED_LIMIT_VARIANT = 'reached';
+export const CLOSE_TO_LIMIT_VARIANT = 'close';
+
export const REACHED_LIMIT_MESSAGE = s__(
- 'InviteMembersModal|You cannot add more members, but you can remove members who no longer need access.',
+ 'InviteMembersModal|To invite new users to this namespace, you must remove existing users. You can still add existing namespace users.',
);
export const REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE = REACHED_LIMIT_MESSAGE.concat(
s__(
- 'InviteMembersModal| To get more members and access to additional paid features, an owner of the group can start a trial or upgrade to a paid tier.',
+ 'InviteMembersModal| To get more members, the owner of this namespace can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.',
),
);
diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/components/graphql/mutations/move_issue.mutation.graphql b/app/assets/javascripts/issuable/bulk_update_sidebar/components/graphql/mutations/move_issue.mutation.graphql
new file mode 100644
index 00000000000..d350072425b
--- /dev/null
+++ b/app/assets/javascripts/issuable/bulk_update_sidebar/components/graphql/mutations/move_issue.mutation.graphql
@@ -0,0 +1,5 @@
+mutation moveIssue($moveIssueInput: IssueMoveInput!) {
+ issueMove(input: $moveIssueInput) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/components/move_issues_button.vue b/app/assets/javascripts/issuable/bulk_update_sidebar/components/move_issues_button.vue
new file mode 100644
index 00000000000..6e287ac3bb7
--- /dev/null
+++ b/app/assets/javascripts/issuable/bulk_update_sidebar/components/move_issues_button.vue
@@ -0,0 +1,171 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import IssuableMoveDropdown from '~/vue_shared/components/sidebar/issuable_move_dropdown.vue';
+import createFlash from '~/flash';
+import { logError } from '~/lib/logger';
+import { s__ } from '~/locale';
+import {
+ WORK_ITEM_TYPE_ENUM_ISSUE,
+ WORK_ITEM_TYPE_ENUM_INCIDENT,
+ WORK_ITEM_TYPE_ENUM_TASK,
+ WORK_ITEM_TYPE_ENUM_TEST_CASE,
+} from '~/work_items/constants';
+import issuableEventHub from '~/issues/list/eventhub';
+import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
+import getIssuesCountQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql';
+import moveIssueMutation from './graphql/mutations/move_issue.mutation.graphql';
+
+export default {
+ name: 'MoveIssuesButton',
+ components: {
+ IssuableMoveDropdown,
+ GlAlert,
+ },
+ props: {
+ projectFullPath: {
+ type: String,
+ required: true,
+ },
+ projectsFetchPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selectedIssuables: [],
+ moveInProgress: false,
+ };
+ },
+ computed: {
+ cannotMoveTasksWarningTitle() {
+ if (this.tasksSelected && this.testCasesSelected) {
+ return s__('Issues|Tasks and test cases can not be moved.');
+ }
+
+ if (this.testCasesSelected) {
+ return s__('Issues|Test cases can not be moved.');
+ }
+
+ return s__('Issues|Tasks can not be moved.');
+ },
+ issuesSelected() {
+ return this.selectedIssuables.some((item) => item.type === WORK_ITEM_TYPE_ENUM_ISSUE);
+ },
+ incidentsSelected() {
+ return this.selectedIssuables.some((item) => item.type === WORK_ITEM_TYPE_ENUM_INCIDENT);
+ },
+ tasksSelected() {
+ return this.selectedIssuables.some((item) => item.type === WORK_ITEM_TYPE_ENUM_TASK);
+ },
+ testCasesSelected() {
+ return this.selectedIssuables.some((item) => item.type === WORK_ITEM_TYPE_ENUM_TEST_CASE);
+ },
+ },
+ mounted() {
+ issuableEventHub.$on('issuables:issuableChecked', this.handleIssuableChecked);
+ },
+ beforeDestroy() {
+ issuableEventHub.$off('issuables:issuableChecked', this.handleIssuableChecked);
+ },
+ methods: {
+ handleIssuableChecked(issuable, value) {
+ if (value) {
+ this.selectedIssuables.push(issuable);
+ } else {
+ const index = this.selectedIssuables.indexOf(issuable);
+ if (index > -1) {
+ this.selectedIssuables.splice(index, 1);
+ }
+ }
+ },
+ moveIssues(targetProject) {
+ const iids = this.selectedIssuables.reduce((result, issueData) => {
+ if (
+ issueData.type === WORK_ITEM_TYPE_ENUM_ISSUE ||
+ issueData.type === WORK_ITEM_TYPE_ENUM_INCIDENT
+ ) {
+ result.push(issueData.iid);
+ }
+ return result;
+ }, []);
+
+ if (iids.length === 0) {
+ return;
+ }
+
+ this.moveInProgress = true;
+ issuableEventHub.$emit('issuables:bulkMoveStarted');
+
+ const promises = iids.map((id) => {
+ return this.moveIssue(id, targetProject);
+ });
+
+ Promise.all(promises)
+ .then((promisesResult) => {
+ let foundError = false;
+
+ for (const promiseResult of promisesResult) {
+ if (promiseResult.data.issueMove?.errors?.length) {
+ foundError = true;
+ logError(
+ `Error moving issue. Error message: ${promiseResult.data.issueMove.errors[0].message}`,
+ );
+ }
+ }
+
+ if (!foundError) {
+ const client = this.$apollo.provider.defaultClient;
+ client.refetchQueries({
+ include: [getIssuesQuery, getIssuesCountQuery],
+ });
+ this.moveInProgress = false;
+ this.selectedIssuables = [];
+ issuableEventHub.$emit('issuables:bulkMoveEnded');
+ } else {
+ throw new Error();
+ }
+ })
+ .catch(() => {
+ this.moveInProgress = false;
+ issuableEventHub.$emit('issuables:bulkMoveEnded');
+
+ createFlash({
+ message: s__(`Issues|There was an error while moving the issues.`),
+ });
+ });
+ },
+ moveIssue(issueIid, targetProject) {
+ return this.$apollo.mutate({
+ mutation: moveIssueMutation,
+ variables: {
+ moveIssueInput: {
+ projectPath: this.projectFullPath,
+ iid: issueIid,
+ targetProjectPath: targetProject.full_path,
+ },
+ },
+ });
+ },
+ },
+ i18n: {
+ dropdownButtonTitle: s__('Issues|Move selected'),
+ },
+};
+</script>
+<template>
+ <div>
+ <issuable-move-dropdown
+ :project-full-path="projectFullPath"
+ :projects-fetch-path="projectsFetchPath"
+ :move-in-progress="moveInProgress"
+ :disabled="!issuesSelected && !incidentsSelected"
+ :dropdown-header-title="$options.i18n.dropdownButtonTitle"
+ :dropdown-button-title="$options.i18n.dropdownButtonTitle"
+ @move-issuable="moveIssues"
+ />
+ <gl-alert v-if="tasksSelected || testCasesSelected" :dismissible="false" variant="warning">
+ {{ cannotMoveTasksWarningTitle }}
+ </gl-alert>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/index.js b/app/assets/javascripts/issuable/bulk_update_sidebar/index.js
index 4657771353f..b7cb805ee37 100644
--- a/app/assets/javascripts/issuable/bulk_update_sidebar/index.js
+++ b/app/assets/javascripts/issuable/bulk_update_sidebar/index.js
@@ -1,6 +1,9 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { gqlClient } from '../../issues/list/graphql';
import StatusDropdown from './components/status_dropdown.vue';
import SubscriptionsDropdown from './components/subscriptions_dropdown.vue';
+import MoveIssuesButton from './components/move_issues_button.vue';
import issuableBulkUpdateActions from './issuable_bulk_update_actions';
import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar';
@@ -42,3 +45,31 @@ export function initSubscriptionsDropdown() {
render: (createElement) => createElement(SubscriptionsDropdown),
});
}
+
+export function initMoveIssuesButton() {
+ const el = document.querySelector('.js-move-issues');
+
+ if (!el) {
+ return null;
+ }
+
+ const { dataset } = el;
+
+ Vue.use(VueApollo);
+ const apolloProvider = new VueApollo({
+ defaultClient: gqlClient,
+ });
+
+ return new Vue({
+ el,
+ name: 'MoveIssuesRoot',
+ apolloProvider,
+ render: (createElement) =>
+ createElement(MoveIssuesButton, {
+ props: {
+ projectFullPath: dataset.projectFullPath,
+ projectsFetchPath: dataset.projectsFetchPath,
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js
index a33c6ae8030..b46a95c7dfa 100644
--- a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js
@@ -3,7 +3,7 @@
import $ from 'jquery';
import issuableEventHub from '~/issues/list/eventhub';
import LabelsSelect from '~/labels/labels_select';
-import MilestoneSelect from '~/milestones/milestone_select';
+import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
const HIDDEN_CLASS = 'hidden';
@@ -46,35 +46,28 @@ export default class IssuableBulkUpdateSidebar {
// https://gitlab.com/gitlab-org/gitlab/-/issues/325874
issuableEventHub.$on('issuables:enableBulkEdit', () => this.toggleBulkEdit(null, true));
issuableEventHub.$on('issuables:updateBulkEdit', () => this.updateFormState());
+
+ // These events are connected to the logic inside `move_issues_button.vue`,
+ // so that only one action can be performed at a time
+ issuableEventHub.$on('issuables:bulkMoveStarted', () => this.toggleSubmitButtonDisabled(true));
+ issuableEventHub.$on('issuables:bulkMoveEnded', () => this.updateFormState());
}
initDropdowns() {
new LabelsSelect();
- new MilestoneSelect();
+ mountMilestoneDropdown();
// Checking IS_EE and using ee_else_ce is odd, but we do it here to satisfy
// the import/no-unresolved lint rule when FOSS_ONLY=1, even though at
// runtime this block won't execute.
if (IS_EE) {
- import('ee_else_ce/vue_shared/components/sidebar/health_status_select/health_status_bundle')
- .then(({ default: HealthStatusSelect }) => {
- HealthStatusSelect();
- })
- .catch(() => {});
-
- import('ee_else_ce/vue_shared/components/sidebar/epics_select/epics_select_bundle')
- .then(({ default: EpicSelect }) => {
- EpicSelect();
+ import('ee_else_ce/sidebar/mount_sidebar')
+ .then(({ mountEpicDropdown, mountHealthStatusDropdown, mountIterationDropdown }) => {
+ mountEpicDropdown();
+ mountHealthStatusDropdown();
+ mountIterationDropdown();
})
.catch(() => {});
-
- import('ee_else_ce/vue_shared/components/sidebar/iterations_dropdown_bundle')
- .then(({ default: iterationsDropdown }) => {
- iterationsDropdown();
- })
- .catch((e) => {
- throw e;
- });
}
}
@@ -89,6 +82,8 @@ export default class IssuableBulkUpdateSidebar {
this.updateSelectedIssuableIds();
IssuableBulkUpdateActions.setOriginalDropdownData();
+
+ issuableEventHub.$emit('issuables:selectionChanged', !noCheckedIssues);
}
prepForSubmit() {
diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index 8894e8f63b8..254248ef1d4 100644
--- a/app/assets/javascripts/issuable/components/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -141,6 +141,7 @@ export default {
<gl-link
:href="computedPath"
class="sortable-link gl-font-weight-normal"
+ target="_blank"
@click="handleTitleClick"
>
{{ title }}
diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
new file mode 100644
index 00000000000..29f6aecca03
--- /dev/null
+++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlButton, GlEmptyState } from '@gitlab/ui';
+import { __ } from '~/locale';
+import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
+import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
+
+export default {
+ i18n: {
+ calendarButtonText: __('Subscribe to calendar'),
+ emptyStateTitle: __('Please select at least one filter to see results'),
+ rssButtonText: __('Subscribe to RSS feed'),
+ searchInputPlaceholder: __('Search or filter results...'),
+ },
+ IssuableListTabs,
+ components: {
+ GlButton,
+ GlEmptyState,
+ IssuableList,
+ },
+ inject: ['calendarPath', 'emptyStateSvgPath', 'isSignedIn', 'rssPath'],
+ data() {
+ return {
+ issues: [],
+ searchTokens: [],
+ sortOptions: [],
+ state: IssuableStates.Opened,
+ };
+ },
+};
+</script>
+
+<template>
+ <issuable-list
+ namespace="dashboard"
+ recent-searches-storage-key="issues"
+ :search-input-placeholder="$options.i18n.searchInputPlaceholder"
+ :search-tokens="searchTokens"
+ :sort-options="sortOptions"
+ :issuables="issues"
+ :tabs="$options.IssuableListTabs"
+ :current-tab="state"
+ >
+ <template #nav-actions>
+ <gl-button :href="rssPath" icon="rss">
+ {{ $options.i18n.rssButtonText }}
+ </gl-button>
+ <gl-button :href="calendarPath" icon="calendar">
+ {{ $options.i18n.calendarButtonText }}
+ </gl-button>
+ </template>
+
+ <template #empty-state>
+ <gl-empty-state :svg-path="emptyStateSvgPath" :title="$options.i18n.emptyStateTitle" />
+ </template>
+ </issuable-list>
+</template>
diff --git a/app/assets/javascripts/issues/dashboard/index.js b/app/assets/javascripts/issues/dashboard/index.js
new file mode 100644
index 00000000000..a1ae3b93f7d
--- /dev/null
+++ b/app/assets/javascripts/issues/dashboard/index.js
@@ -0,0 +1,25 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import IssuesDashboardApp from './components/issues_dashboard_app.vue';
+
+export function mountIssuesDashboardApp() {
+ const el = document.querySelector('.js-issues-dashboard');
+
+ if (!el) {
+ return null;
+ }
+
+ const { calendarPath, emptyStateSvgPath, isSignedIn, rssPath } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'IssuesDashboardRoot',
+ provide: {
+ calendarPath,
+ emptyStateSvgPath,
+ isSignedIn: parseBoolean(isSignedIn),
+ rssPath,
+ },
+ render: (createComponent) => createComponent(IssuesDashboardApp),
+ });
+}
diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js
index 22ac37656ea..a785790169d 100644
--- a/app/assets/javascripts/issues/index.js
+++ b/app/assets/javascripts/issues/index.js
@@ -18,9 +18,9 @@ import {
} from '~/issues/show';
import { parseIssuableData } from '~/issues/show/utils/parse_data';
import LabelsSelect from '~/labels/labels_select';
-import MilestoneSelect from '~/milestones/milestone_select';
import initNotesApp from '~/notes';
import { store } from '~/notes/stores';
+import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar';
import ZenMode from '~/zen_mode';
import initAwardsApp from '~/emoji/awards_app';
import initLinkedResources from '~/linked_resources';
@@ -41,11 +41,11 @@ export function initForm() {
new IssuableForm($('.issue-form')); // eslint-disable-line no-new
new IssuableTemplateSelectors({ warnTemplateOverride: true }); // eslint-disable-line no-new
new LabelsSelect(); // eslint-disable-line no-new
- new MilestoneSelect(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
initTitleSuggestions();
initTypePopover();
+ mountMilestoneDropdown();
}
export function initShow() {
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 acb6aa93f0f..64de4b1947b 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -27,6 +27,7 @@ import { getParameterByName, joinPaths } from '~/lib/utils/url_utility';
import { helpPagePath } from '~/helpers/help_page_helper';
import {
DEFAULT_NONE_ANY,
+ FILTERED_SEARCH_TERM,
OPERATOR_IS_ONLY,
TOKEN_TITLE_ASSIGNEE,
TOKEN_TITLE_AUTHOR,
@@ -38,12 +39,22 @@ import {
TOKEN_TITLE_ORGANIZATION,
TOKEN_TITLE_RELEASE,
TOKEN_TITLE_TYPE,
- FILTERED_SEARCH_TERM,
+ OPERATOR_IS_NOT_OR,
+ OPERATOR_IS_AND_IS_NOT,
+ TOKEN_TYPE_ASSIGNEE,
+ TOKEN_TYPE_AUTHOR,
+ TOKEN_TYPE_CONFIDENTIAL,
+ TOKEN_TYPE_CONTACT,
+ TOKEN_TYPE_LABEL,
+ TOKEN_TYPE_MILESTONE,
+ TOKEN_TYPE_MY_REACTION,
+ TOKEN_TYPE_ORGANIZATION,
+ TOKEN_TYPE_RELEASE,
+ 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 glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { WORK_ITEM_TYPE_ENUM_TASK } from '~/work_items/constants';
import {
CREATED_DESC,
defaultTypeTokenOptions,
@@ -59,16 +70,6 @@ import {
PARAM_SORT,
PARAM_STATE,
RELATIVE_POSITION_ASC,
- TOKEN_TYPE_ASSIGNEE,
- TOKEN_TYPE_AUTHOR,
- TOKEN_TYPE_CONFIDENTIAL,
- TOKEN_TYPE_CONTACT,
- TOKEN_TYPE_LABEL,
- TOKEN_TYPE_MILESTONE,
- TOKEN_TYPE_MY_REACTION,
- TOKEN_TYPE_ORGANIZATION,
- TOKEN_TYPE_RELEASE,
- TOKEN_TYPE_TYPE,
TYPE_TOKEN_TASK_OPTION,
UPDATED_DESC,
urlSortParams,
@@ -140,7 +141,6 @@ export default {
'hasAnyProjects',
'hasBlockedIssuesFeature',
'hasIssueWeightsFeature',
- 'hasMultipleIssueAssigneesFeature',
'hasScopedLabelsFeature',
'initialEmail',
'initialSort',
@@ -239,21 +239,17 @@ export default {
state: this.state,
...this.pageParams,
...this.apiFilterParams,
- types: this.apiFilterParams.types || this.defaultWorkItemTypes,
+ types: this.apiFilterParams.types || defaultWorkItemTypes,
};
},
namespace() {
return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
},
- defaultWorkItemTypes() {
- return this.isWorkItemsEnabled
- ? defaultWorkItemTypes
- : defaultWorkItemTypes.filter((type) => type !== WORK_ITEM_TYPE_ENUM_TASK);
- },
typeTokenOptions() {
- return this.isWorkItemsEnabled
- ? defaultTypeTokenOptions.concat(TYPE_TOKEN_TASK_OPTION)
- : defaultTypeTokenOptions;
+ return defaultTypeTokenOptions.concat(TYPE_TOKEN_TASK_OPTION);
+ },
+ hasOrFeature() {
+ return this.glFeatures.orIssuableQueries;
},
hasSearch() {
return (
@@ -272,9 +268,6 @@ export default {
isOpenTab() {
return this.state === IssuableStates.Opened;
},
- isWorkItemsEnabled() {
- return this.glFeatures.workItems;
- },
showCsvButtons() {
return this.isProject && this.isSignedIn;
},
@@ -324,8 +317,8 @@ export default {
icon: 'user',
token: AuthorToken,
dataType: 'user',
- unique: !this.hasMultipleIssueAssigneesFeature,
defaultAuthors: DEFAULT_NONE_ANY,
+ operators: this.hasOrFeature ? OPERATOR_IS_NOT_OR : OPERATOR_IS_AND_IS_NOT,
fetchAuthors: this.fetchUsers,
recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`,
preloadedAuthors,
@@ -565,6 +558,7 @@ export default {
bulkUpdateSidebar.initBulkUpdateSidebar('issuable_');
bulkUpdateSidebar.initStatusDropdown();
bulkUpdateSidebar.initSubscriptionsDropdown();
+ bulkUpdateSidebar.initMoveIssuesButton();
const usersSelect = await import('~/users_select');
const UsersSelect = usersSelect.default;
@@ -793,6 +787,7 @@ export default {
:show-page-size-change-controls="showPageSizeControls"
:has-next-page="pageInfo.hasNextPage"
:has-previous-page="pageInfo.hasPreviousPage"
+ :show-filtered-search-friendly-text="hasOrFeature"
show-work-item-type-icon
@click-tab="handleClickTab"
@dismiss-alert="handleDismissAlert"
@@ -959,12 +954,17 @@ export default {
<gl-empty-state
v-else
- :description="$options.i18n.noIssuesSignedOutDescription"
:title="$options.i18n.noIssuesSignedOutTitle"
:svg-path="emptyStateSvgPath"
:primary-button-text="$options.i18n.noIssuesSignedOutButtonText"
:primary-button-link="signInPath"
- />
+ >
+ <template #description>
+ <gl-link :href="issuesHelpPagePath" target="_blank">{{
+ $options.i18n.noIssuesSignedOutDescription
+ }}</gl-link>
+ </template>
+ </gl-empty-state>
<issuable-by-email v-if="showIssuableByEmail" class="gl-text-center gl-pt-5 gl-pb-7" />
</div>
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 9fe8899ab39..5ed9ceea856 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -7,7 +7,21 @@ import {
FILTER_UPCOMING,
OPERATOR_IS,
OPERATOR_IS_NOT,
+ OPERATOR_OR,
+ TOKEN_TYPE_ASSIGNEE,
+ TOKEN_TYPE_AUTHOR,
+ TOKEN_TYPE_CONFIDENTIAL,
+ TOKEN_TYPE_CONTACT,
+ TOKEN_TYPE_EPIC,
TOKEN_TYPE_HEALTH,
+ TOKEN_TYPE_ITERATION,
+ TOKEN_TYPE_LABEL,
+ TOKEN_TYPE_MILESTONE,
+ TOKEN_TYPE_MY_REACTION,
+ TOKEN_TYPE_ORGANIZATION,
+ TOKEN_TYPE_RELEASE,
+ TOKEN_TYPE_TYPE,
+ TOKEN_TYPE_WEIGHT,
} from '~/vue_shared/components/filtered_search_bar/constants';
import {
WORK_ITEM_TYPE_ENUM_INCIDENT,
@@ -46,10 +60,8 @@ export const i18n = {
noIssuesSignedInDescription: __('Learn more about issues.'),
noIssuesSignedInTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'),
noIssuesSignedOutButtonText: __('Register / Sign In'),
- noIssuesSignedOutDescription: __(
- 'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.',
- ),
- noIssuesSignedOutTitle: __('There are no issues to show'),
+ noIssuesSignedOutDescription: __('Learn more about issues.'),
+ noIssuesSignedOutTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'),
noSearchResultsDescription: __('To widen your search, change or remove filters above'),
noSearchResultsTitle: __('Sorry, your filter produced no results'),
relatedMergeRequests: __('Related merge requests'),
@@ -136,20 +148,6 @@ export const specialFilterValues = [
FILTER_STARTED,
];
-export const TOKEN_TYPE_AUTHOR = 'author_username';
-export const TOKEN_TYPE_ASSIGNEE = 'assignee_username';
-export const TOKEN_TYPE_MILESTONE = 'milestone';
-export const TOKEN_TYPE_LABEL = 'labels';
-export const TOKEN_TYPE_TYPE = 'type';
-export const TOKEN_TYPE_RELEASE = 'release';
-export const TOKEN_TYPE_MY_REACTION = 'my_reaction_emoji';
-export const TOKEN_TYPE_CONFIDENTIAL = 'confidential';
-export const TOKEN_TYPE_ITERATION = 'iteration';
-export const TOKEN_TYPE_EPIC = 'epic_id';
-export const TOKEN_TYPE_WEIGHT = 'weight';
-export const TOKEN_TYPE_CONTACT = 'crm_contact';
-export const TOKEN_TYPE_ORGANIZATION = 'crm_organization';
-
export const TYPE_TOKEN_TASK_OPTION = { icon: 'issue-type-task', title: 'task', value: 'task' };
// This should be consistent with Issue::TYPES_FOR_LIST in the backend
@@ -195,6 +193,9 @@ export const filters = {
[OPERATOR_IS_NOT]: {
[NORMAL_FILTER]: 'not[assignee_username][]',
},
+ [OPERATOR_OR]: {
+ [NORMAL_FILTER]: 'or[assignee_username][]',
+ },
},
},
[TOKEN_TYPE_MILESTONE]: {
diff --git a/app/assets/javascripts/issues/list/graphql.js b/app/assets/javascripts/issues/list/graphql.js
new file mode 100644
index 00000000000..5ef61727a3d
--- /dev/null
+++ b/app/assets/javascripts/issues/list/graphql.js
@@ -0,0 +1,25 @@
+import produce from 'immer';
+import createDefaultClient from '~/lib/graphql';
+import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
+
+const resolvers = {
+ Mutation: {
+ reorderIssues: (_, { oldIndex, newIndex, namespace, serializedVariables }, { cache }) => {
+ const variables = JSON.parse(serializedVariables);
+ const sourceData = cache.readQuery({ query: getIssuesQuery, variables });
+
+ const data = produce(sourceData, (draftData) => {
+ const issues = draftData[namespace].issues.nodes.slice();
+ const issueToMove = issues[oldIndex];
+ issues.splice(oldIndex, 1);
+ issues.splice(newIndex, 0, issueToMove);
+
+ draftData[namespace].issues.nodes = issues;
+ });
+
+ cache.writeQuery({ query: getIssuesQuery, variables, data });
+ },
+ },
+};
+
+export const gqlClient = createDefaultClient(resolvers);
diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js
index 93333c31b34..5e04dd1971c 100644
--- a/app/assets/javascripts/issues/list/index.js
+++ b/app/assets/javascripts/issues/list/index.js
@@ -1,12 +1,11 @@
-import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
-import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
import IssuesListApp from 'ee_else_ce/issues/list/components/issues_list_app.vue';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue';
+import { gqlClient } from './graphql';
export function mountJiraIssuesListApp() {
const el = document.querySelector('.js-jira-issues-import-status');
@@ -56,26 +55,6 @@ export function mountIssuesListApp() {
Vue.use(VueApollo);
Vue.use(VueRouter);
- const resolvers = {
- Mutation: {
- reorderIssues: (_, { oldIndex, newIndex, namespace, serializedVariables }, { cache }) => {
- const variables = JSON.parse(serializedVariables);
- const sourceData = cache.readQuery({ query: getIssuesQuery, variables });
-
- const data = produce(sourceData, (draftData) => {
- const issues = draftData[namespace].issues.nodes.slice();
- const issueToMove = issues[oldIndex];
- issues.splice(oldIndex, 1);
- issues.splice(newIndex, 0, issueToMove);
-
- draftData[namespace].issues.nodes = issues;
- });
-
- cache.writeQuery({ query: getIssuesQuery, variables, data });
- },
- },
- };
-
const {
autocompleteAwardEmojisPath,
calendarPath,
@@ -97,7 +76,6 @@ export function mountIssuesListApp() {
hasIssuableHealthStatusFeature,
hasIssueWeightsFeature,
hasIterationsFeature,
- hasMultipleIssueAssigneesFeature,
hasScopedLabelsFeature,
importCsvIssuesPath,
initialEmail,
@@ -125,7 +103,7 @@ export function mountIssuesListApp() {
el,
name: 'IssuesListRoot',
apolloProvider: new VueApollo({
- defaultClient: createDefaultClient(resolvers),
+ defaultClient: gqlClient,
}),
router: new VueRouter({
base: window.location.pathname,
@@ -148,7 +126,6 @@ export function mountIssuesListApp() {
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasIterationsFeature: parseBoolean(hasIterationsFeature),
- hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature),
hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature),
initialSort,
isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled),
diff --git a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
index df7016aeb74..b447289b425 100644
--- a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
@@ -24,6 +24,7 @@ query getIssues(
$crmContactId: String
$crmOrganizationId: String
$not: NegatedIssueFilterInput
+ $or: UnionedIssueFilterInput
$beforeCursor: String
$afterCursor: String
$firstPageSize: Int
@@ -49,6 +50,7 @@ query getIssues(
crmContactId: $crmContactId
crmOrganizationId: $crmOrganizationId
not: $not
+ or: $or
before: $beforeCursor
after: $afterCursor
first: $firstPageSize
@@ -84,6 +86,7 @@ query getIssues(
crmContactId: $crmContactId
crmOrganizationId: $crmOrganizationId
not: $not
+ or: $or
before: $beforeCursor
after: $afterCursor
first: $firstPageSize
diff --git a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql
index c1aee772167..fdb0eeb5970 100644
--- a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql
@@ -17,6 +17,7 @@ query getIssuesCount(
$crmContactId: String
$crmOrganizationId: String
$not: NegatedIssueFilterInput
+ $or: UnionedIssueFilterInput
) {
group(fullPath: $fullPath) @skip(if: $isProject) {
id
@@ -37,6 +38,7 @@ query getIssuesCount(
crmContactId: $crmContactId
crmOrganizationId: $crmOrganizationId
not: $not
+ or: $or
) {
count
}
@@ -57,6 +59,7 @@ query getIssuesCount(
crmContactId: $crmContactId
crmOrganizationId: $crmOrganizationId
not: $not
+ or: $or
) {
count
}
@@ -77,6 +80,7 @@ query getIssuesCount(
crmContactId: $crmContactId
crmOrganizationId: $crmOrganizationId
not: $not
+ or: $or
) {
count
}
@@ -101,6 +105,7 @@ query getIssuesCount(
crmContactId: $crmContactId
crmOrganizationId: $crmOrganizationId
not: $not
+ or: $or
) {
count
}
@@ -122,6 +127,7 @@ query getIssuesCount(
crmContactId: $crmContactId
crmOrganizationId: $crmOrganizationId
not: $not
+ or: $or
) {
count
}
@@ -143,6 +149,7 @@ query getIssuesCount(
crmContactId: $crmContactId
crmOrganizationId: $crmOrganizationId
not: $not
+ or: $or
) {
count
}
diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js
index f02c7a23f51..2f9ab9d62ee 100644
--- a/app/assets/javascripts/issues/list/utils.js
+++ b/app/assets/javascripts/issues/list/utils.js
@@ -5,6 +5,13 @@ import { __ } from '~/locale';
import {
FILTERED_SEARCH_TERM,
OPERATOR_IS_NOT,
+ OPERATOR_OR,
+ TOKEN_TYPE_ASSIGNEE,
+ TOKEN_TYPE_CONFIDENTIAL,
+ TOKEN_TYPE_ITERATION,
+ TOKEN_TYPE_MILESTONE,
+ TOKEN_TYPE_RELEASE,
+ TOKEN_TYPE_TYPE,
} from '~/vue_shared/components/filtered_search_bar/constants';
import {
API_PARAM,
@@ -31,12 +38,6 @@ import {
specialFilterValues,
TITLE_ASC,
TITLE_DESC,
- TOKEN_TYPE_ASSIGNEE,
- TOKEN_TYPE_CONFIDENTIAL,
- TOKEN_TYPE_ITERATION,
- TOKEN_TYPE_MILESTONE,
- TOKEN_TYPE_RELEASE,
- TOKEN_TYPE_TYPE,
UPDATED_ASC,
UPDATED_DESC,
URL_PARAM,
@@ -252,20 +253,36 @@ const formatData = (token) => {
export const convertToApiParams = (filterTokens) => {
const params = {};
const not = {};
+ const or = {};
filterTokens
.filter((token) => token.type !== FILTERED_SEARCH_TERM)
.forEach((token) => {
const filterType = getFilterType(token.value.data, token.type);
const field = filters[token.type][API_PARAM][filterType];
- const obj = token.value.operator === OPERATOR_IS_NOT ? not : params;
+ let obj;
+ if (token.value.operator === OPERATOR_IS_NOT) {
+ obj = not;
+ } else if (token.value.operator === OPERATOR_OR) {
+ obj = or;
+ } else {
+ obj = params;
+ }
const data = formatData(token);
Object.assign(obj, {
[field]: obj[field] ? [obj[field], data].flat() : data,
});
});
- return Object.keys(not).length ? Object.assign(params, { not }) : params;
+ if (Object.keys(not).length) {
+ Object.assign(params, { not });
+ }
+
+ if (Object.keys(or).length) {
+ Object.assign(params, { or });
+ }
+
+ return params;
};
export const convertToUrlParams = (filterTokens) =>
diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue
index dbe634e7295..180dea77003 100644
--- a/app/assets/javascripts/issues/show/components/fields/description.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description.vue
@@ -67,7 +67,7 @@ export default {
:quick-actions-docs-path="quickActionsDocsPath"
:enable-autocomplete="enableAutocomplete"
supports-quick-actions
- init-on-autofocus
+ autofocus
@input="$emit('input', $event)"
@keydown.meta.enter="updateIssuable"
@keydown.ctrl.enter="updateIssuable"
diff --git a/app/assets/javascripts/issues/show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue
index 75d0b9e5e76..5695efd7114 100644
--- a/app/assets/javascripts/issues/show/components/fields/type.vue
+++ b/app/assets/javascripts/issues/show/components/fields/type.vue
@@ -42,7 +42,7 @@ export default {
const {
issueState: { issueType },
} = this;
- return capitalize(issueType);
+ return issuableTypes.find((type) => type.value === issueType)?.text || capitalize(issueType);
},
shouldShowIncident() {
return this.issueType === INCIDENT_TYPE || this.canCreateIncident;
diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue
index 74d166f82bb..c01de63ced9 100644
--- a/app/assets/javascripts/issues/show/components/header_actions.vue
+++ b/app/assets/javascripts/issues/show/components/header_actions.vue
@@ -7,6 +7,7 @@ import {
GlLink,
GlModal,
GlModalDirective,
+ GlTooltipDirective,
} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import createFlash, { FLASH_TYPES } from '~/flash';
@@ -51,6 +52,7 @@ export default {
},
directives: {
GlModal: GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
mixins: [trackingMixin],
inject: {
@@ -287,12 +289,15 @@ export default {
<gl-dropdown
v-if="hasDesktopDropdown"
+ v-gl-tooltip.hover
class="gl-display-none gl-sm-display-inline-flex! gl-ml-3"
icon="ellipsis_v"
category="tertiary"
data-qa-selector="issue_actions_ellipsis_dropdown"
:text="dropdownText"
:text-sr-only="true"
+ :title="dropdownText"
+ :aria-label="dropdownText"
data-testid="desktop-dropdown"
no-caret
right
diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js
index aa7b9805b5f..db846009409 100644
--- a/app/assets/javascripts/issues/show/components/incidents/constants.js
+++ b/app/assets/javascripts/issues/show/components/incidents/constants.js
@@ -1,4 +1,4 @@
-import { __, s__ } from '~/locale';
+import { __, n__, s__ } from '~/locale';
export const timelineTabI18n = Object.freeze({
title: s__('Incident|Timeline'),
@@ -15,6 +15,8 @@ export const timelineFormI18n = Object.freeze({
save: __('Save'),
cancel: __('Cancel'),
description: __('Description'),
+ hint: __('You can enter up to 280 characters'),
+ textRemaining: (count) => n__('%d character remaining', '%d characters remaining', count),
saveAndAdd: s__('Incident|Save and add another event'),
areaLabel: s__('Incident|Timeline text'),
});
@@ -38,3 +40,5 @@ export const timelineItemI18n = Object.freeze({
moreActions: __('More actions'),
timeUTC: __('%{time} UTC'),
});
+
+export const MAX_TEXT_LENGTH = 280;
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 55cd8b5f606..72dfccca467 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
@@ -2,7 +2,7 @@
import { GlDatepicker, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
-import { timelineFormI18n } from './constants';
+import { MAX_TEXT_LENGTH, timelineFormI18n } from './constants';
import { getUtcShiftedDate } from './utils';
export default {
@@ -26,6 +26,7 @@ export default {
GlButton,
},
i18n: timelineFormI18n,
+ MAX_TEXT_LENGTH,
directives: {
autofocusonshow,
},
@@ -63,6 +64,9 @@ export default {
};
},
computed: {
+ isTimelineTextValid() {
+ return this.timelineTextCount > 0 && this.timelineTextRemainingCount >= 0;
+ },
occurredAtString() {
const year = this.datePickerInput.getFullYear();
const month = this.datePickerInput.getMonth();
@@ -74,8 +78,11 @@ export default {
return utcDate.toISOString();
},
- hasTimelineText() {
- return this.timelineText.length > 0;
+ timelineTextRemainingCount() {
+ return MAX_TEXT_LENGTH - this.timelineTextCount;
+ },
+ timelineTextCount() {
+ return this.timelineText.length;
},
},
mounted() {
@@ -158,9 +165,21 @@ export default {
dir="auto"
data-supports-quick-actions="false"
:aria-label="$options.i18n.description"
+ aria-describedby="timeline-form-hint"
:placeholder="$options.i18n.areaPlaceholder"
+ :maxlength="$options.MAX_TEXT_LENGTH"
>
</textarea>
+ <div id="timeline-form-hint" class="gl-sr-only">{{ $options.i18n.hint }}</div>
+ <div
+ aria-hidden="true"
+ class="gl-absolute gl-text-gray-500 gl-font-sm gl-line-height-14 gl-right-4 gl-bottom-3"
+ >
+ {{ timelineTextRemainingCount }}
+ </div>
+ <div role="status" class="gl-sr-only">
+ {{ $options.i18n.textRemaining(timelineTextRemainingCount) }}
+ </div>
</template>
</markdown-field>
</gl-form-group>
@@ -171,7 +190,7 @@ export default {
category="primary"
class="gl-mr-3"
data-testid="save-button"
- :disabled="!hasTimelineText"
+ :disabled="!isTimelineTextValid"
:loading="isEventProcessed"
@click="handleSave(false)"
>
@@ -183,7 +202,7 @@ export default {
category="secondary"
class="gl-mr-3 gl-ml-n2"
data-testid="save-and-add-button"
- :disabled="!hasTimelineText"
+ :disabled="!isTimelineTextValid"
:loading="isEventProcessed"
@click="handleSave(true)"
>
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue
index 263b2d141c9..64b497c3550 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue
@@ -35,6 +35,11 @@ export default {
retryButtonCategory() {
return this.job.status && this.job.recoverable ? 'primary' : 'secondary';
},
+ buttonTitle() {
+ return this.job.status && this.job.status.text === 'passed'
+ ? this.$options.i18n.runAgainJobButtonLabel
+ : this.$options.i18n.retryJobButtonLabel;
+ },
},
methods: {
...mapActions(['toggleSidebar']),
@@ -66,8 +71,8 @@ export default {
<job-sidebar-retry-button
v-if="job.retry_path"
v-gl-tooltip.left
- :title="$options.i18n.retryJobButtonLabel"
- :aria-label="$options.i18n.retryJobButtonLabel"
+ :title="buttonTitle"
+ :aria-label="buttonTitle"
:category="retryButtonCategory"
:href="job.retry_path"
:modal-id="$options.forwardDeploymentFailureModalId"
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue
index b0db48df01f..aac6a0ad6d3 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue
@@ -63,10 +63,23 @@ export default {
commit() {
return this.job?.pipeline?.commit || {};
},
+ selectedStageData() {
+ return this.stages.find((val) => val.name === this.selectedStage);
+ },
shouldShowJobRetryForwardDeploymentModal() {
return this.job.retry_path && this.hasForwardDeploymentFailure;
},
},
+ watch: {
+ job(value, oldValue) {
+ const hasNewStatus = value.status.text !== oldValue.status.text;
+ const isCurrentStage = value?.stage === this.selectedStage;
+
+ if (hasNewStatus && isCurrentStage) {
+ this.fetchJobsForStage(this.selectedStageData);
+ }
+ },
+ },
methods: {
...mapActions(['fetchJobsForStage']),
},
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 120f01db8f0..d1b2da4d115 100644
--- a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
@@ -1,15 +1,16 @@
<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { formatDate, getTimeago, durationTimeFormatted } from '~/lib/utils/datetime_utility';
+import { GlIcon } from '@gitlab/ui';
+import { durationTimeFormatted } from '~/lib/utils/datetime_utility';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
iconSize: 12,
- directives: {
- GlTooltip: GlTooltipDirective,
- },
components: {
GlIcon,
+ TimeAgoTooltip,
},
+ mixins: [timeagoMixin],
props: {
job: {
type: Object,
@@ -23,12 +24,6 @@ export default {
duration() {
return this.job?.duration;
},
- timeFormatted() {
- return getTimeago().format(this.finishedTime);
- },
- tooltipTitle() {
- return formatDate(this.finishedTime);
- },
durationFormatted() {
return durationTimeFormatted(this.duration);
},
@@ -44,15 +39,7 @@ export default {
</div>
<div v-if="finishedTime" data-testid="job-finished-time">
<gl-icon name="calendar" :size="$options.iconSize" data-testid="finished-time-icon" />
- <time
- v-gl-tooltip
- :title="tooltipTitle"
- :datetime="finishedTime"
- data-placement="top"
- data-container="body"
- >
- {{ timeFormatted }}
- </time>
+ <time-ago-tooltip :time="finishedTime" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js
index 50ee7bd20dd..e9475994e8b 100644
--- a/app/assets/javascripts/jobs/constants.js
+++ b/app/assets/javascripts/jobs/constants.js
@@ -15,6 +15,7 @@ export const JOB_SIDEBAR_COPY = {
retry: __('Retry'),
retryJobButtonLabel: s__('Job|Retry'),
toggleSidebar: __('Toggle Sidebar'),
+ runAgainJobButtonLabel: s__('Job|Run again'),
};
export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = {
diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js
index ba801082377..36f387205f8 100644
--- a/app/assets/javascripts/lazy_loader.js
+++ b/app/assets/javascripts/lazy_loader.js
@@ -169,7 +169,8 @@ export default class LazyLoader {
delete img.dataset.src;
img.classList.remove('lazy');
img.classList.add('js-lazy-loaded');
- img.classList.add('qa-js-lazy-loaded');
+ // eslint-disable-next-line no-param-reassign
+ img.dataset.qa_selector = 'js_lazy_loaded_content';
}
}
}
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 4448a106bb6..beced4f9144 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -634,66 +634,6 @@ export const NavigationType = {
};
/**
- * Method to perform case-insensitive search for a string
- * within multiple properties and return object containing
- * properties in case there are multiple matches or `null`
- * if there's no match.
- *
- * Eg; Suppose we want to allow user to search using for a string
- * within `iid`, `title`, `url` or `reference` props of a target object;
- *
- * const objectToSearch = {
- * "iid": 1,
- * "title": "Error omnis quos consequatur ullam a vitae sed omnis libero cupiditate. &3",
- * "url": "/groups/gitlab-org/-/epics/1",
- * "reference": "&1",
- * };
- *
- * Following is how we call searchBy and the return values it will yield;
- *
- * - `searchBy('omnis', objectToSearch);`: This will return `{ title: ... }` as our
- * query was found within title prop we only return that.
- * - `searchBy('1', objectToSearch);`: This will return `{ "iid": ..., "reference": ..., "url": ... }`.
- * - `searchBy('https://gitlab.com/groups/gitlab-org/-/epics/1', objectToSearch);`:
- * This will return `{ "url": ... }`.
- * - `searchBy('foo', objectToSearch);`: This will return `null` as no property value
- * matched with our query.
- *
- * You can learn more about behaviour of this method by referring to tests
- * within `spec/frontend/lib/utils/common_utils_spec.js`.
- *
- * @param {string} query String to search for
- * @param {object} searchSpace Object containing properties to search in for `query`
- */
-export const searchBy = (query = '', searchSpace = {}) => {
- const targetKeys = searchSpace !== null ? Object.keys(searchSpace) : [];
-
- if (!query || !targetKeys.length) {
- return null;
- }
-
- const normalizedQuery = query.toLowerCase();
- const matches = targetKeys
- .filter((item) => {
- const searchItem = `${searchSpace[item]}`.toLowerCase();
-
- return (
- searchItem.indexOf(normalizedQuery) > -1 ||
- normalizedQuery.indexOf(searchItem) > -1 ||
- normalizedQuery === searchItem
- );
- })
- .reduce((acc, prop) => {
- const match = acc;
- match[prop] = searchSpace[prop];
-
- return acc;
- }, {});
-
- return Object.keys(matches).length ? matches : null;
-};
-
-/**
* Checks if the given Label has a special syntax `::` in
* it's title.
*
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
new file mode 100644
index 00000000000..3bfbfea7f22
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_action.js
@@ -0,0 +1,57 @@
+import Vue from 'vue';
+
+export function confirmAction(
+ message,
+ {
+ primaryBtnVariant,
+ primaryBtnText,
+ secondaryBtnVariant,
+ secondaryBtnText,
+ cancelBtnVariant,
+ cancelBtnText,
+ modalHtmlMessage,
+ title,
+ hideCancel,
+ } = {},
+) {
+ return new Promise((resolve) => {
+ let confirmed = false;
+ let component;
+
+ const ConfirmAction = Vue.extend({
+ components: {
+ ConfirmModal: () => import('./confirm_modal.vue'),
+ },
+ render(h) {
+ return h(
+ 'confirm-modal',
+ {
+ props: {
+ secondaryText: secondaryBtnText,
+ secondaryVariant: secondaryBtnVariant,
+ primaryVariant: primaryBtnVariant,
+ primaryText: primaryBtnText,
+ cancelVariant: cancelBtnVariant,
+ cancelText: cancelBtnText,
+ title,
+ modalHtmlMessage,
+ hideCancel,
+ },
+ on: {
+ confirmed() {
+ confirmed = true;
+ },
+ closed() {
+ component.$destroy();
+ resolve(confirmed);
+ },
+ },
+ },
+ [message],
+ );
+ },
+ });
+
+ component = new ConfirmAction().$mount();
+ });
+}
diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
index 2dc479db80a..0e959e899e9 100644
--- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
+++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
@@ -1,75 +1,21 @@
-import Vue from 'vue';
+import { confirmAction } from './confirm_action';
-export function confirmAction(
- message,
- {
- primaryBtnVariant,
- primaryBtnText,
- secondaryBtnVariant,
- secondaryBtnText,
- cancelBtnVariant,
- cancelBtnText,
- modalHtmlMessage,
- title,
- hideCancel,
- } = {},
-) {
- return new Promise((resolve) => {
- let confirmed = false;
-
- const component = new Vue({
- components: {
- ConfirmModal: () => import('./confirm_modal.vue'),
- },
- render(h) {
- return h(
- 'confirm-modal',
- {
- props: {
- secondaryText: secondaryBtnText,
- secondaryVariant: secondaryBtnVariant,
- primaryVariant: primaryBtnVariant,
- primaryText: primaryBtnText,
- cancelVariant: cancelBtnVariant,
- cancelText: cancelBtnText,
- title,
- modalHtmlMessage,
- hideCancel,
- },
- on: {
- confirmed() {
- confirmed = true;
- },
- closed() {
- component.$destroy();
- resolve(confirmed);
- },
- },
- },
- [message],
- );
- },
- }).$mount();
- });
-}
-
-export function confirmViaGlModal(message, element) {
- const primaryBtnConfig = {};
-
- const { confirmBtnVariant } = element.dataset;
-
- if (confirmBtnVariant) {
- primaryBtnConfig.primaryBtnVariant = confirmBtnVariant;
- }
+function confirmViaGlModal(message, element) {
+ const { confirmBtnVariant, title, isHtmlMessage } = element.dataset;
const screenReaderText =
element.querySelector('.gl-sr-only')?.textContent ||
element.querySelector('.sr-only')?.textContent ||
element.getAttribute('aria-label');
- if (screenReaderText) {
- primaryBtnConfig.primaryBtnText = screenReaderText;
- }
+ const config = {
+ ...(screenReaderText && { primaryBtnText: screenReaderText }),
+ ...(confirmBtnVariant && { primaryBtnVariant: confirmBtnVariant }),
+ ...(title && { title }),
+ ...(isHtmlMessage && { modalHtmlMessage: message }),
+ };
- return confirmAction(message, primaryBtnConfig);
+ return confirmAction(message, config);
}
+
+export { confirmAction, confirmViaGlModal };
diff --git a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
index c11cf1a7882..4e0a59d0a38 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
@@ -271,6 +271,25 @@ export const secondsToMilliseconds = (seconds) => seconds * 1000;
export const secondsToDays = (seconds) => Math.round(seconds / 86400);
/**
+ * Returns the date `n` seconds after the date provided
+ *
+ * @param {Date} date the initial date
+ * @param {Number} numberOfSeconds number of seconds after
+ * @return {Date} A `Date` object `n` seconds after the provided `Date`
+ */
+export const nSecondsAfter = (date, numberOfSeconds) =>
+ new Date(date.getTime() + numberOfSeconds * 1000);
+
+/**
+ * Returns the date `n` seconds before the date provided
+ *
+ * @param {Date} date the initial date
+ * @param {Number} numberOfSeconds number of seconds before
+ * @return {Date} A `Date` object `n` seconds before the provided `Date`
+ */
+export const nSecondsBefore = (date, numberOfSeconds) => nSecondsAfter(date, -numberOfSeconds);
+
+/**
* Returns the date `n` days after the date provided
*
* @param {Date} date the initial date
diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js
index bca6978c206..cafee641174 100644
--- a/app/assets/javascripts/lib/utils/dom_utils.js
+++ b/app/assets/javascripts/lib/utils/dom_utils.js
@@ -106,3 +106,15 @@ export const setAttributes = (el, attributes) => {
el.setAttribute(key, attributes[key]);
});
};
+
+/**
+ * Get the height of the wrapper page element
+ * This height can be used to determine where the highest element goes in a page
+ * Useful for gl-drawer's header-height prop
+ * @param {String} contentWrapperClass the content wrapper class
+ * @returns {String} height in px
+ */
+export const getContentWrapperHeight = (contentWrapperClass) => {
+ const wrapperEl = document.querySelector(contentWrapperClass);
+ return wrapperEl ? `${wrapperEl.offsetTop}px` : '';
+};
diff --git a/app/assets/javascripts/lib/utils/unit_format/index.js b/app/assets/javascripts/lib/utils/unit_format/index.js
index 5c5210027e4..bec7e48addc 100644
--- a/app/assets/javascripts/lib/utils/unit_format/index.js
+++ b/app/assets/javascripts/lib/utils/unit_format/index.js
@@ -22,6 +22,7 @@ export const SUPPORTED_FORMATS = {
percentHundred: 'percentHundred',
// Duration
+ days: 'days',
seconds: 'seconds',
milliseconds: 'milliseconds',
@@ -65,6 +66,9 @@ export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => {
}
// Durations
+ if (format === SUPPORTED_FORMATS.days) {
+ return suffixFormatter(s__('Units|d'));
+ }
if (format === SUPPORTED_FORMATS.seconds) {
return suffixFormatter(s__('Units|s'));
}
@@ -161,6 +165,19 @@ export const percent = getFormatter(SUPPORTED_FORMATS.percent);
export const percentHundred = getFormatter(SUPPORTED_FORMATS.percentHundred);
/**
+ * Formats a number of days
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is rendered as `1d`
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
+ */
+export const days = getFormatter(SUPPORTED_FORMATS.days);
+
+/**
* Formats a number of seconds
*
* @function
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index ca90eee69c7..b1a0baf8150 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -178,7 +178,7 @@ export function mergeUrlParams(params, url, options = {}) {
const mergedKeys = sort ? Object.keys(merged).sort() : Object.keys(merged);
const newQuery = mergedKeys
- .filter((key) => merged[key] !== null)
+ .filter((key) => merged[key] !== null && merged[key] !== undefined)
.map((key) => {
let value = merged[key];
const encodedKey = encodeURIComponent(key);
diff --git a/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue
index d092283338c..f4893721b9e 100644
--- a/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue
@@ -7,15 +7,12 @@ import RemoveMemberButton from './remove_member_button.vue';
export default {
name: 'AccessRequestActionButtons',
components: { ActionButtonGroup, RemoveMemberButton, ApproveAccessRequestButton },
+ inheritAttrs: false,
props: {
member: {
type: Object,
required: true,
},
- permissions: {
- type: Object,
- required: true,
- },
isCurrentUser: {
type: Boolean,
required: true,
@@ -43,10 +40,10 @@ export default {
<template>
<action-button-group>
- <div v-if="permissions.canUpdate" class="gl-px-1">
+ <div class="gl-px-1">
<approve-access-request-button :member-id="member.id" />
</div>
- <div v-if="permissions.canRemove" class="gl-px-1">
+ <div class="gl-px-1">
<remove-member-button
:member-id="member.id"
:message="message"
diff --git a/app/assets/javascripts/members/components/members_tabs.vue b/app/assets/javascripts/members/components/members_tabs.vue
index b824a013f3b..5ac8b30614d 100644
--- a/app/assets/javascripts/members/components/members_tabs.vue
+++ b/app/assets/javascripts/members/components/members_tabs.vue
@@ -27,13 +27,13 @@ export const TABS = [
{
namespace: MEMBER_TYPES.invite,
title: __('Invited'),
- canManageMembersPermissionsRequired: true,
+ requiredPermissions: ['canManageMembers'],
queryParamValue: TAB_QUERY_PARAM_VALUES.invite,
},
{
namespace: MEMBER_TYPES.accessRequest,
title: __('Access requests'),
- canManageMembersPermissionsRequired: true,
+ requiredPermissions: ['canManageAccessRequests'],
queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest,
},
...EE_TABS,
@@ -44,7 +44,7 @@ export default {
ACTIVE_TAB_QUERY_PARAM_NAME,
TABS,
components: { MembersApp, GlTabs, GlTab, GlBadge, GlButton },
- inject: ['canManageMembers', 'canExportMembers', 'exportCsvPath'],
+ inject: ['canManageMembers', 'canManageAccessRequests', 'canExportMembers', 'exportCsvPath'],
data() {
return {
selectedTabIndex: 0,
@@ -96,15 +96,13 @@ export default {
return true;
}
- const { canManageMembersPermissionsRequired = false } = tab;
+ const { requiredPermissions = [] } = tab;
const tabCanBeShown =
this.getTabCount(tab) > 0 || this.activeTabIndexCalculatedFromUrlParams === index;
- if (canManageMembersPermissionsRequired) {
- return this.canManageMembers && tabCanBeShown;
- }
-
- return tabCanBeShown;
+ return (
+ tabCanBeShown && requiredPermissions.every((requiredPermission) => this[requiredPermission])
+ );
},
},
};
diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js
index 34660f8f499..359239c5c0c 100644
--- a/app/assets/javascripts/members/index.js
+++ b/app/assets/javascripts/members/index.js
@@ -17,6 +17,7 @@ export const initMembersApp = (el, options) => {
const {
sourceId,
canManageMembers,
+ canManageAccessRequests,
canExportMembers,
canFilterByEnterprise,
exportCsvPath,
@@ -61,6 +62,7 @@ export const initMembersApp = (el, options) => {
currentUserId: gon.current_user_id || null,
sourceId,
canManageMembers,
+ canManageAccessRequests,
canFilterByEnterprise,
canExportMembers,
exportCsvPath,
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 17ee2a0d8b6..0ddf5def8ee 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -260,7 +260,7 @@ export default class MergeRequestTabs {
}
}
- tabShown(action, href) {
+ tabShown(action, href, shouldScroll = true) {
if (action !== this.currentTab && this.mergeRequestTabs) {
this.currentTab = action;
@@ -289,7 +289,9 @@ export default class MergeRequestTabs {
}
if (action === 'commits') {
- this.loadCommits(href);
+ if (!this.commitsLoaded) {
+ this.loadCommits(href);
+ }
// this.hideSidebar();
this.resetViewContainer();
this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable);
@@ -334,7 +336,7 @@ export default class MergeRequestTabs {
$('.detail-page-description').renderGFM();
- this.recallScroll(action);
+ if (shouldScroll) this.recallScroll(action);
} else if (action === this.currentAction) {
// ContentTop is used to handle anything at the top of the page before the main content
const mainContentContainer = document.querySelector('.content-wrapper');
@@ -348,7 +350,7 @@ export default class MergeRequestTabs {
const scrollDestination = tabContentTop - mainContentTop - 51;
// scrollBehavior is only available in browsers that support scrollToOptions
- if ('scrollBehavior' in document.documentElement.style) {
+ if ('scrollBehavior' in document.documentElement.style && shouldScroll) {
window.scrollTo({
top: scrollDestination,
behavior: 'smooth',
@@ -423,28 +425,39 @@ export default class MergeRequestTabs {
return this.currentAction;
}
- loadCommits(source) {
- if (this.commitsLoaded) {
- return;
- }
-
+ loadCommits(source, page = 1) {
toggleLoader(true);
axios
- .get(`${source}.json`)
+ .get(`${source}.json`, { params: { page, per_page: 100 } })
.then(({ data }) => {
+ toggleLoader(false);
+
const commitsDiv = document.querySelector('div#commits');
// eslint-disable-next-line no-unsanitized/property
- commitsDiv.innerHTML = data.html;
+ commitsDiv.innerHTML += data.html;
localTimeAgo(commitsDiv.querySelectorAll('.js-timeago'));
this.commitsLoaded = true;
scrollToContainer('#commits');
- toggleLoader(false);
+ const loadMoreButton = document.querySelector('.js-load-more-commits');
+
+ if (loadMoreButton) {
+ loadMoreButton.addEventListener('click', (e) => {
+ e.preventDefault();
+
+ loadMoreButton.remove();
+ this.loadCommits(source, loadMoreButton.dataset.nextPage);
+ });
+ }
+
+ if (!data.next_page) {
+ return import('./add_context_commits_modal');
+ }
- return import('./add_context_commits_modal');
+ return null;
})
- .then((m) => m.default())
+ .then((m) => m?.default())
.catch(() => {
toggleLoader(false);
createAlert({
diff --git a/app/assets/javascripts/milestones/milestone_select.js b/app/assets/javascripts/milestones/milestone_select.js
deleted file mode 100644
index d4876c3dbe8..00000000000
--- a/app/assets/javascripts/milestones/milestone_select.js
+++ /dev/null
@@ -1,273 +0,0 @@
-/* eslint-disable one-var, no-self-compare, consistent-return, no-param-reassign, no-shadow */
-/* global Issuable */
-
-import $ from 'jquery';
-import { template, escape } from 'lodash';
-import Api from '~/api';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { __, sprintf } from '~/locale';
-import { sortMilestonesByDueDate } from '~/milestones/utils';
-import axios from '~/lib/utils/axios_utils';
-import { timeFor, parsePikadayDate, dateInWords } from '~/lib/utils/datetime_utility';
-
-export default class MilestoneSelect {
- constructor(currentProject, els, options = {}) {
- if (currentProject !== null) {
- this.currentProject =
- typeof currentProject === 'string' ? JSON.parse(currentProject) : currentProject;
- }
-
- MilestoneSelect.init(els, options);
- }
-
- static init(els, options) {
- let $els = $(els);
-
- if (!els) {
- $els = $('.js-milestone-select');
- }
-
- $els.each((i, dropdown) => {
- let milestoneLinkNoneTemplate,
- milestoneLinkTemplate,
- milestoneExpiredLinkTemplate,
- selectedMilestone,
- selectedMilestoneDefault;
- const $dropdown = $(dropdown);
- const issueUpdateURL = $dropdown.data('issueUpdate');
- const showNo = $dropdown.data('showNo');
- const showAny = $dropdown.data('showAny');
- const showMenuAbove = $dropdown.data('showMenuAbove');
- const showUpcoming = $dropdown.data('showUpcoming');
- const showStarted = $dropdown.data('showStarted');
- const useId = $dropdown.data('useId');
- const defaultLabel = $dropdown.data('defaultLabel');
- const defaultNo = $dropdown.data('defaultNo');
- const abilityName = $dropdown.data('abilityName');
- const $selectBox = $dropdown.closest('.selectbox');
- const $block = $selectBox.closest('.block');
- const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon');
- const $value = $block.find('.value');
- const $loading = $block.find('.block-loading').addClass('gl-display-none');
- selectedMilestoneDefault = showAny ? '' : null;
- selectedMilestoneDefault =
- showNo && defaultNo ? __('No milestone') : selectedMilestoneDefault;
- selectedMilestone = $dropdown.data('selected') || selectedMilestoneDefault;
-
- if (issueUpdateURL) {
- milestoneLinkTemplate = template(
- '<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>',
- );
- milestoneExpiredLinkTemplate = template(
- '<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %> (Past due)</a>',
- );
- milestoneLinkNoneTemplate = `<span class="no-value">${__('None')}</span>`;
- }
- return initDeprecatedJQueryDropdown($dropdown, {
- showMenuAbove,
- data: (term, callback) => {
- let contextId = parseInt($dropdown.get(0).dataset.projectId, 10);
- let getMilestones = Api.projectMilestones.bind(Api);
- const reqParams = { state: 'active', include_parent_milestones: true };
-
- if (term) {
- reqParams.search = term.trim();
- }
-
- if (!contextId) {
- contextId = $dropdown.get(0).dataset.groupId;
- delete reqParams.include_parent_milestones;
- getMilestones = Api.groupMilestones.bind(Api);
- }
-
- // We don't use $.data() as it caches initial value and never updates!
- return getMilestones(contextId, reqParams)
- .then(({ data }) =>
- data
- .map((m) => ({
- ...m,
- // Public API includes `title` instead of `name`.
- name: m.title,
- }))
- .sort(sortMilestonesByDueDate),
- )
- .then((data) => {
- const extraOptions = [];
- if (showAny) {
- extraOptions.push({
- id: null,
- name: null,
- title: __('Any milestone'),
- });
- }
- if (showNo && term.trim() === '') {
- extraOptions.push({
- id: -1,
- name: __('No milestone'),
- title: __('No milestone'),
- });
- }
- if (showUpcoming) {
- extraOptions.push({
- id: -2,
- name: '#upcoming',
- title: __('Upcoming'),
- });
- }
- if (showStarted) {
- extraOptions.push({
- id: -3,
- name: '#started',
- title: __('Started'),
- });
- }
- if (extraOptions.length && data.length) {
- extraOptions.push({ type: 'divider' });
- }
-
- callback(extraOptions.concat(data));
- if (showMenuAbove) {
- $dropdown.data('deprecatedJQueryDropdown').positionMenuAbove();
- }
- $(`[data-milestone-id="${selectedMilestone}"] > a`).addClass('is-active');
- });
- },
- renderRow: (milestone) => {
- const milestoneName = milestone.title || milestone.name;
- let milestoneDisplayName = escape(milestoneName);
-
- if (milestone.expired) {
- milestoneDisplayName = sprintf(__('%{milestone} (expired)'), {
- milestone: milestoneDisplayName,
- });
- }
-
- return `
- <li data-milestone-id="${escape(milestoneName)}">
- <a href='#' class='dropdown-menu-milestone-link'>
- ${milestoneDisplayName}
- </a>
- </li>
- `;
- },
- filterable: true,
- filterRemote: true,
- search: {
- fields: ['title'],
- },
- selectable: true,
- toggleLabel: (selected, el) => {
- if (selected && 'id' in selected && $(el).hasClass('is-active')) {
- return selected.title;
- }
- return defaultLabel;
- },
- defaultLabel,
- fieldName: $dropdown.data('fieldName'),
- text: (milestone) => escape(milestone.title),
- id: (milestone) => {
- if (milestone !== undefined) {
- if (!useId && !$dropdown.is('.js-issuable-form-dropdown')) {
- return milestone.name;
- }
-
- return milestone.id;
- }
- },
- hidden: () => {
- $selectBox.hide();
- // display:block overrides the hide-collapse rule
- return $value.css('display', '');
- },
- opened: (e) => {
- const $el = $(e.currentTarget);
- if (options.handleClick) {
- selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault;
- }
- $('a.is-active', $el).removeClass('is-active');
- $(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active');
- },
- vue: false,
- clicked: (clickEvent) => {
- const { e } = clickEvent;
- let selected = clickEvent.selectedObj;
-
- if (!selected) return;
-
- if (options.handleClick) {
- e.preventDefault();
- options.handleClick(selected);
- return;
- }
-
- const page = $('body').attr('data-page');
- const isIssueIndex = page === 'projects:issues:index';
- const isMRIndex = page === page && page === 'projects:merge_requests:index';
- const isSelecting = selected.name !== selectedMilestone;
- selectedMilestone = isSelecting ? selected.name : selectedMilestoneDefault;
-
- if (
- $dropdown.hasClass('js-filter-bulk-update') ||
- $dropdown.hasClass('js-issuable-form-dropdown')
- ) {
- e.preventDefault();
- return;
- }
-
- if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
- return Issuable.filterResults($dropdown.closest('form'));
- } else if ($dropdown.hasClass('js-filter-submit')) {
- return $dropdown.closest('form').submit();
- }
-
- selected = $selectBox.find('input[type="hidden"]').val();
-
- const data = {};
- data[abilityName] = {};
- data[abilityName].milestone_id = selected != null ? selected : null;
- $loading.removeClass('gl-display-none');
- $dropdown.trigger('loading.gl.dropdown');
- return axios
- .put(issueUpdateURL, data)
- .then(({ data }) => {
- $dropdown.trigger('loaded.gl.dropdown');
- $loading.addClass('gl-display-none');
- $selectBox.hide();
- $value.css('display', '');
- if (data.milestone != null) {
- data.milestone.remaining = timeFor(data.milestone.due_date);
- data.milestone.name = data.milestone.title;
- $value.html(
- data.milestone.expired
- ? milestoneExpiredLinkTemplate({
- ...data.milestone,
- remaining: sprintf(__('%{due_date} (Past due)'), {
- due_date: dateInWords(parsePikadayDate(data.milestone.due_date)),
- }),
- })
- : milestoneLinkTemplate(data.milestone),
- );
- return $sidebarCollapsedValue
- .attr(
- 'data-original-title',
- `${data.milestone.name}<br />${data.milestone.remaining}`,
- )
- .find('span')
- .text(data.milestone.title);
- }
- $value.html(milestoneLinkNoneTemplate);
- return $sidebarCollapsedValue
- .attr('data-original-title', __('Milestone'))
- .find('span')
- .text(__('None'));
- })
- .catch(() => {
- $loading.addClass('gl-display-none');
- });
- },
- });
- });
- }
-}
-
-window.MilestoneSelect = MilestoneSelect;
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/experiment.vue b/app/assets/javascripts/ml/experiment_tracking/components/experiment.vue
new file mode 100644
index 00000000000..73cdfbc44b0
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/components/experiment.vue
@@ -0,0 +1,36 @@
+<script>
+import { GlTable } from '@gitlab/ui';
+import IncubationAlert from './incubation_alert.vue';
+
+export default {
+ name: 'ShowMlExperiment',
+ components: {
+ GlTable,
+ IncubationAlert,
+ },
+ inject: ['candidates', 'metricNames', 'paramNames'],
+ computed: {
+ fields() {
+ return [...this.paramNames, ...this.metricNames];
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <incubation-alert />
+
+ <h3>
+ {{ __('Experiment Candidates') }}
+ </h3>
+
+ <gl-table
+ :fields="fields"
+ :items="candidates"
+ :empty-text="__('This Experiment has no logged Candidates')"
+ show-empty
+ class="gl-mt-0!"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue b/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue
new file mode 100644
index 00000000000..51c1e935677
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue
@@ -0,0 +1,48 @@
+<script>
+import { GlAlert, GlLink } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ titleLabel: __('Machine Learning Experiment Tracking is in Incubating Phase'),
+ contentLabel: __(
+ 'GitLab incubates features to explore new use cases. These features are updated regularly, and support is limited',
+ ),
+ learnMoreLabel: __('Learn More'),
+ feedbackLabel: __('Feedback and Updates'),
+ },
+ name: 'MlopsIncubationAlert',
+ components: { GlAlert, GlLink },
+ data() {
+ return {
+ isAlertDismissed: false,
+ };
+ },
+ computed: {
+ shouldShowAlert() {
+ return !this.isAlertDismissed;
+ },
+ },
+ methods: {
+ dismissAlert() {
+ this.isAlertDismissed = true;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-alert
+ v-if="shouldShowAlert"
+ :title="$options.i18n.titleLabel"
+ variant="warning"
+ :primary-button-text="$options.i18n.feedbackLabel"
+ primary-button-link="https://gitlab.com/groups/gitlab-org/-/epics/8560"
+ @dismiss="dismissAlert"
+ >
+ {{ $options.i18n.contentLabel }}
+ <gl-link href="https://about.gitlab.com/handbook/engineering/incubation/" target="_blank">{{
+ $options.i18n.learnMoreLabel
+ }}</gl-link>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 127e046b5a9..9aa6abd9d8c 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -148,6 +148,11 @@ export default {
type: Object,
required: true,
},
+ hidePrompt: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
markdown() {
@@ -163,7 +168,7 @@ export default {
<template>
<div class="cell text-cell">
- <prompt />
+ <prompt v-if="!hidePrompt" />
<div v-safe-html:[$options.markdownConfig]="markdown" class="markdown"></div>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index 88d01ffa659..bd01534089e 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -3,6 +3,9 @@ import CodeOutput from '../code/index.vue';
import HtmlOutput from './html.vue';
import ImageOutput from './image.vue';
import LatexOutput from './latex.vue';
+import MarkdownOutput from './markdown.vue';
+
+const TEXT_MARKDOWN = 'text/markdown';
export default {
props: {
@@ -35,6 +38,8 @@ export default {
return 'text/latex';
} else if (output.data['image/svg+xml']) {
return 'image/svg+xml';
+ } else if (output.data[TEXT_MARKDOWN]) {
+ return TEXT_MARKDOWN;
}
return 'text/plain';
@@ -42,7 +47,7 @@ export default {
dataForType(output, type) {
let data = output.data[type];
- if (typeof data === 'object') {
+ if (typeof data === 'object' && this.outputType(output) !== TEXT_MARKDOWN) {
data = data.join('');
}
@@ -61,6 +66,8 @@ export default {
return LatexOutput;
} else if (output.data['image/svg+xml']) {
return HtmlOutput;
+ } else if (output.data[TEXT_MARKDOWN]) {
+ return MarkdownOutput;
}
return CodeOutput;
diff --git a/app/assets/javascripts/notebook/cells/output/markdown.vue b/app/assets/javascripts/notebook/cells/output/markdown.vue
new file mode 100644
index 00000000000..5da057dee72
--- /dev/null
+++ b/app/assets/javascripts/notebook/cells/output/markdown.vue
@@ -0,0 +1,42 @@
+<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import Prompt from '../prompt.vue';
+import Markdown from '../markdown.vue';
+
+export default {
+ name: 'MarkdownOutput',
+ components: {
+ Prompt,
+ Markdown,
+ },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ count: {
+ type: Number,
+ required: true,
+ },
+ rawCode: {
+ type: Array,
+ required: true,
+ },
+ index: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ markdownContent() {
+ return { source: this.rawCode };
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="output">
+ <prompt type="Out" :count="count" :show-output="index === 0" />
+ <markdown :cell="markdownContent" :hide-prompt="true" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
index 2dbc9b10836..3e8cddc3174 100644
--- a/app/assets/javascripts/notes/components/discussion_notes.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -52,6 +52,11 @@ export default {
required: false,
default: false,
},
+ shouldScrollToNote: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
...mapGetters(['userCanReply']),
@@ -133,6 +138,7 @@ export default {
:discussion-root="true"
:discussion-resolve-path="discussion.resolve_path"
:is-overview-tab="isOverviewTab"
+ :should-scroll-to-note="shouldScrollToNote"
@handleDeleteNote="$emit('deleteNote')"
@startReplying="$emit('startReplying')"
>
@@ -183,6 +189,7 @@ export default {
:discussion-root="index === 0"
:discussion-resolve-path="discussion.resolve_path"
:is-overview-tab="isOverviewTab"
+ :should-scroll-to-note="shouldScrollToNote"
@handleDeleteNote="$emit('deleteNote')"
>
<template #avatar-badge>
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index f3530344181..63c7010983e 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -198,7 +198,7 @@ export default {
<gl-badge
v-if="isInternalNote"
v-gl-tooltip:tooltipcontainer.bottom
- data-testid="internalNoteIndicator"
+ data-testid="internal-note-indicator"
variant="warning"
size="sm"
class="gl-ml-2"
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 50d166b6db5..b668d6ec182 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -73,6 +73,11 @@ export default {
required: false,
default: false,
},
+ shouldScrollToNote: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -288,6 +293,7 @@ export default {
:line="line"
:should-group-replies="shouldGroupReplies"
:is-overview-tab="isOverviewTab"
+ :should-scroll-to-note="shouldScrollToNote"
@startReplying="showReplyForm"
@deleteNote="deleteNoteHandler"
>
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index c4b3111b919..8ce0c2f8648 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -91,6 +91,11 @@ export default {
required: false,
default: false,
},
+ shouldScrollToNote: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -222,7 +227,7 @@ export default {
},
mounted() {
- if (this.isTarget) {
+ if (this.isTarget && this.shouldScrollToNote) {
this.scrollToNoteIfNeeded($(this.$el));
}
},
diff --git a/app/assets/javascripts/notes/components/notes_activity_header.vue b/app/assets/javascripts/notes/components/notes_activity_header.vue
index e4f88962731..9c3b2139a5d 100644
--- a/app/assets/javascripts/notes/components/notes_activity_header.vue
+++ b/app/assets/javascripts/notes/components/notes_activity_header.vue
@@ -27,7 +27,7 @@ export default {
<template>
<div
- class="gl-display-flex gl-sm-align-items-center gl-flex-direction-column gl-sm-flex-direction-row gl-justify-content-space-between gl-pt-5 gl-mt-5 gl-border-t"
+ 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"
>
<h2 class="gl-font-size-h1 gl-m-0">{{ __('Activity') }}</h2>
<div class="gl-display-flex gl-gap-3 gl-w-full gl-sm-w-auto gl-mt-3 gl-sm-mt-0">
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 9c2ff2c3e7f..7bb1a1a1bfe 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -126,6 +126,9 @@ export default {
slotKeys() {
return this.sortDirDesc ? ['form', 'comments'] : ['comments', 'form'];
},
+ isAppReady() {
+ return !this.isLoading && !this.renderSkeleton && this.shouldShow;
+ },
},
watch: {
async isFetching() {
@@ -149,6 +152,15 @@ export default {
this.discussionsCount.textContent = val;
}
},
+ isAppReady: {
+ handler(isReady) {
+ if (!isReady) return;
+ this.$nextTick(() => {
+ window.mrTabs?.eventHub.$emit('NotesAppReady');
+ });
+ },
+ immediate: true,
+ },
},
created() {
this.discussionsCount = document.querySelector('.js-discussions-count');
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index d75a4158440..3dbcf28d11c 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -1,8 +1,12 @@
import { mapGetters, mapActions, mapState } from 'vuex';
import { scrollToElement, contentTop } from '~/lib/utils/common_utils';
+function isOverviewPage() {
+ return window.mrTabs?.currentAction === 'show';
+}
+
function getAllDiscussionElements() {
- const containerEl = window.mrTabs?.currentAction === 'diffs' ? '.diffs' : '.notes';
+ const containerEl = isOverviewPage() ? '.tab-pane.notes' : '.diffs';
return Array.from(
document.querySelectorAll(
`${containerEl} div[data-discussion-id]:not([data-discussion-resolved])`,
@@ -59,6 +63,14 @@ function getPreviousDiscussion() {
function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) {
const discussion = getDiscussion();
+ if (!isOverviewPage() && !discussion) {
+ window.mrTabs?.eventHub.$once('NotesAppReady', () => {
+ handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions);
+ });
+ window.mrTabs?.setCurrentAction('show');
+ window.mrTabs?.tabShown('show', undefined, false);
+ return;
+ }
const id = discussion.dataset.discussionId;
ctx.expandDiscussion({ discussionId: id });
scrollToElement(discussion, scrollOptions);
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 6876220f75c..5ad7a811726 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -94,9 +94,9 @@ export const getUserDataByProp = (state) => (prop) => state.userData && state.us
export const descriptionVersions = (state) => state.descriptionVersions;
export const canUserAddIncidentTimelineEvents = (state) => {
- return (
- state.userData.can_add_timeline_events &&
- state.noteableData.type === constants.NOTEABLE_TYPE_MAPPING.Incident
+ return Boolean(
+ state.userData?.can_add_timeline_events &&
+ state.noteableData.type === constants.NOTEABLE_TYPE_MAPPING.Incident,
);
};
diff --git a/app/assets/javascripts/observability/components/observability_app.vue b/app/assets/javascripts/observability/components/observability_app.vue
new file mode 100644
index 00000000000..4f5e27be46f
--- /dev/null
+++ b/app/assets/javascripts/observability/components/observability_app.vue
@@ -0,0 +1,42 @@
+<script>
+export default {
+ props: {
+ observabilityIframeSrc: {
+ type: String,
+ required: true,
+ },
+ },
+ mounted() {
+ window.addEventListener('message', this.messageHandler);
+ },
+ methods: {
+ messageHandler(e) {
+ const isExpectedOrigin = e.origin === new URL(this.observabilityIframeSrc)?.origin;
+
+ const isNewObservabilityPath = this.$route?.query?.observability_path !== e.data?.url;
+
+ const shouldNotHandleMessage = !isExpectedOrigin || !e.data.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: e.data.url },
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <iframe
+ id="observability-ui-iframe"
+ data-testid="observability-ui-iframe"
+ frameborder="0"
+ height="100%"
+ :src="observabilityIframeSrc"
+ ></iframe>
+</template>
diff --git a/app/assets/javascripts/observability/index.js b/app/assets/javascripts/observability/index.js
new file mode 100644
index 00000000000..cd342ebee3e
--- /dev/null
+++ b/app/assets/javascripts/observability/index.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+
+import ObservabilityApp from './components/observability_app.vue';
+
+Vue.use(VueRouter);
+
+export default () => {
+ const el = document.getElementById('js-observability-app');
+
+ if (!el) return false;
+
+ const router = new VueRouter({
+ mode: 'history',
+ });
+
+ return new Vue({
+ el,
+ router,
+ render(h) {
+ return h(ObservabilityApp, {
+ props: {
+ observabilityIframeSrc: el.dataset.observabilityIframeSrc,
+ },
+ });
+ },
+ });
+};
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 80bca536b7c..23d8e97dd79 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
@@ -3,7 +3,6 @@ import { GlTooltipDirective, GlIcon, GlSprintf, GlSkeletonLoader, GlButton } fro
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { n__ } from '~/locale';
import Tracking from '~/tracking';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import { joinPaths } from '~/lib/utils/url_utility';
@@ -38,7 +37,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [Tracking.mixin(), glFeatureFlagsMixin()],
+ mixins: [Tracking.mixin()],
inject: ['config'],
props: {
item: {
@@ -91,17 +90,14 @@ export default {
);
},
imageName() {
- if (this.glFeatures.containerRegistryShowShortenedPath) {
- if (this.showFullPath) {
- return this.item.path;
- }
- const projectPath = this.item?.project?.path?.toLowerCase() ?? '';
- if (this.item.name) {
- return joinPaths(projectPath, this.item.name);
- }
- return projectPath;
+ if (this.showFullPath) {
+ return this.item.path;
}
- return this.item.path;
+ const projectPath = this.item?.project?.path?.toLowerCase() ?? '';
+ if (this.item.name) {
+ return joinPaths(projectPath, this.item.name);
+ }
+ return projectPath;
},
routerLinkEvent() {
return this.deleting ? '' : 'click';
@@ -136,7 +132,7 @@ export default {
>
<template #left-primary>
<gl-button
- v-if="glFeatures.containerRegistryShowShortenedPath && !showFullPath"
+ v-if="!showFullPath"
v-gl-tooltip="{
placement: 'top',
title: $options.i18n.IMAGE_FULL_PATH_LABEL,
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue
index 19d35a135fd..ba8caabb40a 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue
@@ -95,13 +95,8 @@ export default {
<template #right-actions>
<slot name="commands"></slot>
</template>
- <template #metadata-count>
- <metadata-item
- v-if="imagesCount"
- data-testid="images-count"
- icon="container-image"
- :text="imagesCountText"
- />
+ <template v-if="imagesCount" #metadata-count>
+ <metadata-item data-testid="images-count" icon="container-image" :text="imagesCountText" />
</template>
<template #metadata-exp-policies>
<metadata-item
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 9694bfd4e77..9b062024d03 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
@@ -4,11 +4,29 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
+export const mergeVariables = (existing, incoming) => {
+ if (!incoming) return existing;
+ if (!existing) return incoming;
+ return incoming;
+};
+
export const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
batchMax: 1,
+ cacheConfig: {
+ typePolicies: {
+ ContainerRepositoryDetails: {
+ fields: {
+ tags: {
+ keyArgs: ['id'],
+ merge: mergeVariables,
+ },
+ },
+ },
+ },
+ },
},
),
});
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 8b66165a57a..b339c8c8371 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
@@ -31,6 +31,7 @@ import {
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';
const REPOSITORY_IMPORTING_ERROR_MESSAGE = 'repository importing';
@@ -145,6 +146,13 @@ export default {
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,
+ },
+ },
],
});
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue
index 8b6a5c59847..707e8f09045 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue
@@ -1,8 +1,8 @@
<script>
-import { GlPagination, GlModal, GlSprintf } from '@gitlab/ui';
+import { GlPagination } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
-import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
+import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue';
import PackagesListRow from '~/packages_and_registries/infrastructure_registry/shared/package_list_row.vue';
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
import { TRACKING_ACTIONS } from '~/packages_and_registries/shared/constants';
@@ -11,8 +11,7 @@ import { TRACK_CATEGORY } from '~/packages_and_registries/infrastructure_registr
export default {
components: {
GlPagination,
- GlModal,
- GlSprintf,
+ DeletePackageModal,
PackagesListLoader,
PackagesListRow,
},
@@ -42,22 +41,6 @@ export default {
isListEmpty() {
return !this.list || this.list.length === 0;
},
- deletePackageName() {
- return this.itemToBeDeleted?.name ?? '';
- },
- deleteModalActionPrimaryProps() {
- return {
- text: this.$options.i18n.modalAction,
- attributes: {
- variant: 'danger',
- },
- };
- },
- deleteModalActionCancelProps() {
- return {
- text: __('Cancel'),
- };
- },
tracking() {
return {
category: TRACK_CATEGORY,
@@ -68,7 +51,6 @@ export default {
setItemToBeDeleted(item) {
this.itemToBeDeleted = { ...item };
this.track(TRACKING_ACTIONS.REQUEST_DELETE_PACKAGE);
- this.$refs.packageListDeleteModal.show();
},
deleteItemConfirmation() {
this.$emit('package:delete', this.itemToBeDeleted);
@@ -80,11 +62,6 @@ export default {
this.itemToBeDeleted = null;
},
},
- i18n: {
- deleteModalContent: s__('PackageRegistry|You are about to delete %{name}, are you sure?'),
- modalTitle: s__('PackageRegistry|Delete package'),
- modalAction: s__('PackageRegistry|Permanently delete'),
- },
};
</script>
@@ -116,22 +93,11 @@ export default {
class="gl-w-full gl-mt-3"
/>
- <gl-modal
- ref="packageListDeleteModal"
- size="sm"
- modal-id="confirm-delete-package"
- :action-primary="deleteModalActionPrimaryProps"
- :action-cancel="deleteModalActionCancelProps"
+ <delete-package-modal
+ :item-to-be-deleted="itemToBeDeleted"
@ok="deleteItemConfirmation"
@cancel="deleteItemCanceled"
- >
- <template #modal-title>{{ $options.i18n.modalTitle }}</template>
- <gl-sprintf :message="$options.i18n.deleteModalContent">
- <template #name>
- <strong>{{ deletePackageName }}</strong>
- </template>
- </gl-sprintf>
- </gl-modal>
+ />
</template>
</div>
</template>
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
new file mode 100644
index 00000000000..2a1de2ae4a7
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { __, n__ } from '~/locale';
+import {
+ DELETE_PACKAGES_MODAL_TITLE,
+ DELETE_PACKAGE_MODAL_PRIMARY_ACTION,
+} from '~/packages_and_registries/package_registry/constants';
+
+export default {
+ name: 'DeleteModal',
+ i18n: {
+ DELETE_PACKAGES_MODAL_TITLE,
+ },
+ components: {
+ GlModal,
+ },
+ props: {
+ itemsToBeDeleted: {
+ type: Array,
+ required: true,
+ },
+ },
+ 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,
+ );
+ },
+ },
+ modal: {
+ packagesDeletePrimaryAction: {
+ text: DELETE_PACKAGE_MODAL_PRIMARY_ACTION,
+ attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ },
+ cancelAction: {
+ text: __('Cancel'),
+ },
+ },
+ methods: {
+ show() {
+ this.$refs.deleteModal.show();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="deleteModal"
+ size="sm"
+ modal-id="delete-packages-modal"
+ :action-primary="$options.modal.packagesDeletePrimaryAction"
+ :action-cancel="$options.modal.cancelAction"
+ :title="$options.i18n.DELETE_PACKAGES_MODAL_TITLE"
+ @primary="$emit('confirm')"
+ >
+ <span>{{ description }}</span>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
new file mode 100644
index 00000000000..efc60c9c037
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
@@ -0,0 +1,57 @@
+<script>
+import { GlKeysetPagination } from '@gitlab/ui';
+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';
+
+export default {
+ components: {
+ VersionRow,
+ GlKeysetPagination,
+ PackagesListLoader,
+ },
+ props: {
+ versions: {
+ type: Array,
+ required: true,
+ default: () => [],
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ showPagination() {
+ return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
+ },
+ isListEmpty() {
+ return this.versions.length === 0;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div v-if="isLoading">
+ <packages-list-loader />
+ </div>
+ <slot v-else-if="isListEmpty" name="empty-state"></slot>
+ <div v-else>
+ <version-row v-for="version in versions" :key="version.id" :package-entity="version" />
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-keyset-pagination
+ v-if="showPagination"
+ v-bind="pageInfo"
+ 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/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
index 7a000aca0f2..4553dd3421b 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
@@ -2,6 +2,7 @@
import {
GlDropdown,
GlDropdownItem,
+ GlFormCheckbox,
GlIcon,
GlSprintf,
GlTooltipDirective,
@@ -26,6 +27,7 @@ export default {
components: {
GlDropdown,
GlDropdownItem,
+ GlFormCheckbox,
GlIcon,
GlSprintf,
GlTruncate,
@@ -45,6 +47,11 @@ export default {
type: Object,
required: true,
},
+ selected: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
},
computed: {
packageType() {
@@ -90,7 +97,15 @@ export default {
</script>
<template>
- <list-item data-testid="package-row">
+ <list-item data-testid="package-row" v-bind="$attrs">
+ <template #left-action>
+ <gl-form-checkbox
+ v-if="packageEntity.canDestroy"
+ class="gl-m-0"
+ :checked="selected"
+ @change="$emit('select')"
+ />
+ </template>
<template #left-primary>
<div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0">
<router-link
@@ -168,12 +183,9 @@ export default {
category="tertiary"
no-caret
>
- <gl-dropdown-item
- data-testid="action-delete"
- variant="danger"
- @click="$emit('packageToDelete', packageEntity)"
- >{{ $options.i18n.deletePackage }}</gl-dropdown-item
- >
+ <gl-dropdown-item data-testid="action-delete" variant="danger" @click="$emit('delete')">{{
+ $options.i18n.deletePackage
+ }}</gl-dropdown-item>
</gl-dropdown>
</template>
</list-item>
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 c6583b8f09f..ddcddf80c15 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,8 +1,10 @@
<script>
-import { GlAlert, GlModal, GlSprintf, GlKeysetPagination } from '@gitlab/ui';
-import { __, s__, sprintf } from '~/locale';
+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';
import {
DELETE_PACKAGE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
@@ -13,13 +15,13 @@ import { packageTypeToTrackCategory } from '~/packages_and_registries/package_re
import Tracking from '~/tracking';
export default {
+ name: 'PackagesList',
components: {
GlAlert,
- GlKeysetPagination,
- GlModal,
- GlSprintf,
+ DeletePackageModal,
PackagesListLoader,
PackagesListRow,
+ RegistryList,
},
mixins: [Tracking.mixin()],
props: {
@@ -46,12 +48,12 @@ export default {
};
},
computed: {
+ listTitle() {
+ return n__('%d package', '%d packages', this.list.length);
+ },
isListEmpty() {
return !this.list || this.list.length === 0;
},
- deletePackageName() {
- return this.itemToBeDeleted?.name ?? '';
- },
tracking() {
const category = this.itemToBeDeleted
? packageTypeToTrackCategory(this.itemToBeDeleted.packageType)
@@ -60,32 +62,6 @@ export default {
category,
};
},
- showPagination() {
- return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
- },
- showDeleteModal: {
- get() {
- return Boolean(this.itemToBeDeleted);
- },
- set(value) {
- if (!value) {
- this.itemToBeDeleted = null;
- }
- },
- },
- deleteModalActionPrimaryProps() {
- return {
- text: this.$options.i18n.modalAction,
- attributes: {
- variant: 'danger',
- },
- };
- },
- deleteModalActionCancelProps() {
- return {
- text: __('Cancel'),
- };
- },
errorTitleAlert() {
return sprintf(
s__('PackageRegistry|There was an error publishing a %{packageName} package'),
@@ -110,21 +86,28 @@ export default {
this.itemToBeDeleted = { ...item };
this.track(REQUEST_DELETE_PACKAGE_TRACKING_ACTION);
},
+ setItemsToBeDeleted(items) {
+ if (items.length === 1) {
+ const [item] = items;
+ this.setItemToBeDeleted(item);
+ return;
+ }
+ this.$emit('delete', items);
+ },
deleteItemConfirmation() {
this.$emit('package: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]);
},
},
i18n: {
- deleteModalContent: s__('PackageRegistry|You are about to delete %{name}, are you sure?'),
- modalTitle: s__('PackageRegistry|Delete package'),
- modalAction: s__('PackageRegistry|Permanently delete'),
errorMessageBodyAlert: s__(
'PackageRegistry|There was a timeout and the package was not published. Delete this package and try again.',
),
@@ -150,41 +133,32 @@ export default {
@primaryAction="showConfirmationModal"
>{{ $options.i18n.errorMessageBodyAlert }}</gl-alert
>
- <div data-testid="packages-table">
- <packages-list-row
- v-for="packageEntity in list"
- :key="packageEntity.id"
- :package-entity="packageEntity"
- @packageToDelete="setItemToBeDeleted"
- />
- </div>
-
- <div class="gl-display-flex gl-justify-content-center">
- <gl-keyset-pagination
- v-if="showPagination"
- v-bind="pageInfo"
- class="gl-mt-3"
- @prev="$emit('prev-page')"
- @next="$emit('next-page')"
- />
- </div>
+ <registry-list
+ data-testid="packages-table"
+ :is-loading="isLoading"
+ :items="list"
+ :pagination="pageInfo"
+ :title="listTitle"
+ @delete="setItemsToBeDeleted"
+ @prev-page="$emit('prev-page')"
+ @next-page="$emit('next-page')"
+ >
+ <template #default="{ selectItem, isSelected, item, first }">
+ <packages-list-row
+ :first="first"
+ :package-entity="item"
+ :selected="isSelected(item)"
+ @delete="setItemToBeDeleted(item)"
+ @select="selectItem(item)"
+ />
+ </template>
+ </registry-list>
- <gl-modal
- v-model="showDeleteModal"
- modal-id="confirm-delete-package"
- size="sm"
- :action-primary="deleteModalActionPrimaryProps"
- :action-cancel="deleteModalActionCancelProps"
+ <delete-package-modal
+ :item-to-be-deleted="itemToBeDeleted"
@ok="deleteItemConfirmation"
@cancel="deleteItemCanceled"
- >
- <template #modal-title>{{ $options.i18n.modalTitle }}</template>
- <gl-sprintf :message="$options.i18n.deleteModalContent">
- <template #name>
- <strong>{{ deletePackageName }}</strong>
- </template>
- </gl-sprintf>
- </gl-modal>
+ />
</template>
</div>
</template>
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 4e35176c757..b731cd77e66 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -110,6 +110,13 @@ export const FETCH_PACKAGE_PIPELINES_ERROR_MESSAGE = s__(
export const FETCH_PACKAGE_METADATA_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while fetching the package metadata.',
);
+export const DELETE_PACKAGES_ERROR_MESSAGE = s__(
+ 'PackageRegistry|Something went wrong while deleting packages.',
+);
+export const DELETE_PACKAGES_SUCCESS_MESSAGE = s__('PackageRegistry|Packages deleted successfully');
+
+export const DELETE_PACKAGES_MODAL_TITLE = s__('PackageRegistry|Delete packages');
+export const DELETE_PACKAGE_MODAL_PRIMARY_ACTION = s__('PackageRegistry|Permanently delete');
export const DELETE_PACKAGE_SUCCESS_MESSAGE = s__('PackageRegistry|Package deleted successfully');
export const PACKAGE_REGISTRY_TITLE = __('Package Registry');
@@ -177,6 +184,9 @@ export const PACKAGE_TYPES = [
s__('PackageRegistry|Helm'),
];
+export const HIDE_PACKAGE_MIGRATION_SURVEY_COOKIE = 'hide_package_registry_migration_survey';
+export const SURVEY_LINK = 'https://gitlab.fra1.qualtrics.com/jfe/form/SV_cHomH9FPzOaiDTU';
+
// links
export const EMPTY_LIST_HELP_URL = helpPagePath('user/packages/package_registry/index');
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql
new file mode 100644
index 00000000000..e1ff5518bf8
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql
@@ -0,0 +1,5 @@
+mutation destroyPackages($ids: [PackagesPackageID!]!) {
+ destroyPackages(input: { ids: $ids }) {
+ errors
+ }
+}
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 8e50c95b10b..51e0ab5aba8 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,4 +1,10 @@
-query getPackageDetails($id: PackagesPackageID!) {
+query getPackageDetails(
+ $id: PackagesPackageID!
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
+) {
package(id: $id) {
id
name
@@ -55,7 +61,7 @@ query getPackageDetails($id: PackagesPackageID!) {
downloadPath
}
}
- versions(first: 100) {
+ versions(after: $after, before: $before, first: $first, last: $last) {
nodes {
id
name
@@ -69,6 +75,12 @@ query getPackageDetails($id: PackagesPackageID!) {
}
}
}
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ endCursor
+ startCursor
+ }
}
dependencyLinks {
nodes {
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 6680e612985..336eb0ca079 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/index.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/index.js
@@ -36,6 +36,7 @@ export default () => {
const attachMainComponent = () =>
new Vue({
el,
+ name: 'PackageRegistery',
router,
apolloProvider,
provide: {
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 eeed56b77c3..c59dcaee411 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
@@ -22,7 +22,7 @@ import InstallationCommands from '~/packages_and_registries/package_registry/com
import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue';
import PackageHistory from '~/packages_and_registries/package_registry/components/details/package_history.vue';
import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue';
-import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue';
+import PackageVersionsList from '~/packages_and_registries/package_registry/components/details/package_versions_list.vue';
import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue';
import {
PACKAGE_TYPE_NUGET,
@@ -48,6 +48,7 @@ import {
DELETE_MODAL_CONTENT,
DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT,
DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT,
+ GRAPHQL_PAGE_SIZE,
} from '~/packages_and_registries/package_registry/constants';
import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql';
@@ -65,13 +66,13 @@ export default {
GlTabs,
GlSprintf,
PackageTitle,
- VersionRow,
DependencyRow,
PackageHistory,
AdditionalMetadata,
InstallationCommands,
PackageFiles,
DeletePackage,
+ PackageVersionsList,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -132,6 +133,7 @@ export default {
queryVariables() {
return {
id: convertToGraphQLId('Packages::Package', this.packageId),
+ first: GRAPHQL_PAGE_SIZE,
};
},
packageFiles() {
@@ -157,6 +159,9 @@ export default {
hasVersions() {
return this.packageEntity.versions?.nodes?.length > 0;
},
+ versionPageInfo() {
+ return this.packageEntity?.versions?.pageInfo ?? {};
+ },
packageDependencies() {
return this.packageEntity.dependencyLinks?.nodes || [];
},
@@ -264,6 +269,34 @@ 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,
@@ -271,6 +304,7 @@ export default {
deleteFileModalContent: s__(
`PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`,
),
+ otherVersionsTabTitle: __('Other versions'),
},
modal: {
packageDeletePrimaryAction: {
@@ -303,7 +337,7 @@ export default {
:description="s__('PackageRegistry|There was a problem fetching the details for this package.')"
:svg-path="emptyListIllustration"
/>
- <div v-else-if="!isLoading" class="packages-app">
+ <div v-else-if="projectName" class="packages-app">
<package-title :package-entity="packageEntity">
<template #delete-button>
<gl-button
@@ -358,14 +392,20 @@ export default {
</p>
</gl-tab>
- <gl-tab :title="__('Other versions')" title-item-class="js-versions-tab">
- <template v-if="hasVersions">
- <version-row v-for="v in packageEntity.versions.nodes" :key="v.id" :package-entity="v" />
- </template>
-
- <p v-else class="gl-mt-3" data-testid="no-versions-message">
- {{ s__('PackageRegistry|There are no other versions of this package.') }}
- </p>
+ <gl-tab :title="$options.i18n.otherVersionsTabTitle" title-item-class="js-versions-tab" lazy>
+ <package-versions-list
+ :is-loading="isLoading"
+ :page-info="versionPageInfo"
+ :versions="packageEntity.versions.nodes"
+ @prev-page="fetchPreviousVersionsPage"
+ @next-page="fetchNextVersionsPage"
+ >
+ <template #empty-state>
+ <p class="gl-mt-3" data-testid="no-versions-message">
+ {{ s__('PackageRegistry|There are no other versions of this package.') }}
+ </p>
+ </template>
+ </package-versions-list>
</gl-tab>
</gl-tabs>
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 ed9ab0367dd..8b5d51cb856 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,41 +1,52 @@
<script>
-import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlBanner, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { createAlert, VARIANT_INFO } from '~/flash';
-import { historyReplaceState } from '~/lib/utils/common_utils';
+import { getCookie, historyReplaceState, parseBoolean, setCookie } 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,
+ HIDE_PACKAGE_MIGRATION_SURVEY_COOKIE,
DELETE_PACKAGE_SUCCESS_MESSAGE,
+ DELETE_PACKAGES_ERROR_MESSAGE,
+ DELETE_PACKAGES_SUCCESS_MESSAGE,
EMPTY_LIST_HELP_URL,
PACKAGE_HELP_URL,
+ SURVEY_LINK,
} from '~/packages_and_registries/package_registry/constants';
import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql';
-
+import destroyPackagesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql';
import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue';
import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue';
import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue';
import PackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue';
+import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue';
export default {
components: {
+ GlAlert,
+ GlBanner,
GlEmptyState,
GlLink,
GlSprintf,
PackageList,
PackageTitle,
PackageSearch,
+ DeleteModal,
DeletePackage,
},
inject: ['emptyListIllustration', 'isGroupPage', 'fullPath'],
data() {
return {
+ alertVariables: null,
+ itemsToBeDeleted: [],
packages: {},
sort: '',
filters: {},
mutationLoading: false,
+ showSurveyBanner: !parseBoolean(getCookie(HIDE_PACKAGE_MIGRATION_SURVEY_COOKIE)),
};
},
apollo: {
@@ -110,10 +121,53 @@ export default {
historyReplaceState(cleanUrl);
}
},
+ async confirmDelete() {
+ const { itemsToBeDeleted } = this;
+ this.itemsToBeDeleted = [];
+ this.mutationLoading = true;
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: destroyPackagesMutation,
+ variables: {
+ ids: itemsToBeDeleted.map((i) => i.id),
+ },
+ awaitRefetchQueries: true,
+ refetchQueries: [
+ {
+ query: getPackagesQuery,
+ variables: { ...this.queryVariables, first: GRAPHQL_PAGE_SIZE },
+ },
+ ],
+ });
+
+ if (data?.destroyPackages?.errors[0]) {
+ throw new Error(data.destroyPackages.errors[0]);
+ }
+ this.showAlert({
+ variant: 'success',
+ message: DELETE_PACKAGES_SUCCESS_MESSAGE,
+ });
+ } catch {
+ this.showAlert({
+ variant: 'danger',
+ message: DELETE_PACKAGES_ERROR_MESSAGE,
+ });
+ } finally {
+ this.mutationLoading = false;
+ }
+ },
+ showDeletePackagesModal(toBeDeleted) {
+ this.itemsToBeDeleted = toBeDeleted;
+ this.$refs.deletePackagesModal.show();
+ },
handleSearchUpdate({ sort, filters }) {
this.sort = sort;
this.filters = { ...filters };
},
+ hideSurvey() {
+ this.showSurveyBanner = false;
+ setCookie(HIDE_PACKAGE_MIGRATION_SURVEY_COOKIE, 'true');
+ },
updateQuery(_, { fetchMoreResult }) {
return fetchMoreResult;
},
@@ -143,6 +197,9 @@ export default {
updateQuery: this.updateQuery,
});
},
+ showAlert(obj) {
+ this.alertVariables = { ...obj };
+ },
},
i18n: {
widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'),
@@ -151,18 +208,44 @@ export default {
noResultsText: s__(
'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
),
+ surveyBannerTitle: s__('PackageRegistry|Help us learn about your registry migration needs'),
+ surveyBannerDescription: s__(
+ 'PackageRegistry|If you are interested in migrating packages from your private registry to the GitLab Package Registry, take our survey and tell us more about your needs.',
+ ),
+ surveyBannerPrimaryButtonText: s__('PackageRegistry|Take survey'),
},
links: {
EMPTY_LIST_HELP_URL,
PACKAGE_HELP_URL,
},
+ surveyLink: SURVEY_LINK,
};
</script>
<template>
<div>
+ <gl-alert
+ v-if="alertVariables"
+ :variant="alertVariables.variant"
+ class="gl-mt-5"
+ dismissible
+ @dismiss="alertVariables = null"
+ >
+ {{ alertVariables.message }}
+ </gl-alert>
+ <gl-banner
+ v-if="showSurveyBanner"
+ :title="$options.i18n.surveyBannerTitle"
+ :button-text="$options.i18n.surveyBannerPrimaryButtonText"
+ :button-link="$options.surveyLink"
+ class="gl-mt-3"
+ @primary="hideSurvey"
+ @close="hideSurvey"
+ >
+ <p>{{ $options.i18n.surveyBannerDescription }}</p>
+ </gl-banner>
<package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount" />
- <package-search @update="handleSearchUpdate" />
+ <package-search class="gl-mb-5" @update="handleSearchUpdate" />
<delete-package
:refetch-queries="refetchQueriesData"
@@ -178,6 +261,7 @@ export default {
@prev-page="fetchPreviousPage"
@next-page="fetchNextPage"
@package:delete="deletePackage"
+ @delete="showDeletePackagesModal"
>
<template #empty-state>
<gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
@@ -196,5 +280,11 @@ export default {
</package-list>
</template>
</delete-package>
+
+ <delete-modal
+ ref="deletePackagesModal"
+ :items-to-be-deleted="itemsToBeDeleted"
+ @confirm="confirmDelete"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/forwarding_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/forwarding_settings.vue
new file mode 100644
index 00000000000..c7fddadab1b
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/forwarding_settings.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlFormCheckbox, GlFormGroup, GlSprintf } from '@gitlab/ui';
+import { isEqual } from 'lodash';
+import {
+ PACKAGE_FORWARDING_CHECKBOX_LABEL,
+ PACKAGE_FORWARDING_ENFORCE_LABEL,
+} from '~/packages_and_registries/settings/group/constants';
+
+export default {
+ name: 'ForwardingSettings',
+ i18n: {
+ PACKAGE_FORWARDING_CHECKBOX_LABEL,
+ PACKAGE_FORWARDING_ENFORCE_LABEL,
+ },
+ components: {
+ GlFormCheckbox,
+ GlFormGroup,
+ GlSprintf,
+ },
+ props: {
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ forwarding: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ lockForwarding: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ modelNames: {
+ type: Object,
+ required: true,
+ validator(value) {
+ return isEqual(Object.keys(value), ['forwarding', 'lockForwarding', 'isLocked']);
+ },
+ },
+ },
+ computed: {
+ fields() {
+ return [
+ {
+ testid: 'forwarding-checkbox',
+ label: PACKAGE_FORWARDING_CHECKBOX_LABEL,
+ updateField: this.modelNames.forwarding,
+ checked: this.forwarding,
+ },
+ {
+ testid: 'lock-forwarding-checkbox',
+ label: PACKAGE_FORWARDING_ENFORCE_LABEL,
+ updateField: this.modelNames.lockForwarding,
+ checked: this.lockForwarding,
+ },
+ ];
+ },
+ },
+ methods: {
+ update(type, value) {
+ this.$emit('update', type, value);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group :label="label">
+ <gl-form-checkbox
+ v-for="field in fields"
+ :key="field.testid"
+ :checked="field.checked"
+ :disabled="disabled"
+ :data-testid="field.testid"
+ @change="update(field.updateField, $event)"
+ >
+ <gl-sprintf :message="field.label">
+ <template #packageType>
+ {{ label }}
+ </template>
+ </gl-sprintf>
+ </gl-form-checkbox>
+ </gl-form-group>
+</template>
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 f285dfc0755..36eb65c623b 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
@@ -2,6 +2,7 @@
import { GlAlert } from '@gitlab/ui';
import { n__ } from '~/locale';
import PackagesSettings from '~/packages_and_registries/settings/group/components/packages_settings.vue';
+import PackagesForwardingSettings from '~/packages_and_registries/settings/group/components/packages_forwarding_settings.vue';
import DependencyProxySettings from '~/packages_and_registries/settings/group/components/dependency_proxy_settings.vue';
import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql';
@@ -11,6 +12,7 @@ export default {
components: {
GlAlert,
PackagesSettings,
+ PackagesForwardingSettings,
DependencyProxySettings,
},
inject: ['groupPath'],
@@ -82,6 +84,12 @@ export default {
@error="handleError(2)"
/>
+ <packages-forwarding-settings
+ :forward-settings="packageSettings"
+ @success="handleSuccess(2)"
+ @error="handleError(2)"
+ />
+
<dependency-proxy-settings
:dependency-proxy-settings="dependencyProxySettings"
:dependency-proxy-image-ttl-policy="dependencyProxyImageTtlPolicy"
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
new file mode 100644
index 00000000000..b7d7f0aaca7
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue
@@ -0,0 +1,190 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { isEqual } from 'lodash';
+import {
+ PACKAGE_FORWARDING_SETTINGS_HEADER,
+ PACKAGE_FORWARDING_SETTINGS_DESCRIPTION,
+ PACKAGE_FORWARDING_FORM_BUTTON,
+ PACKAGE_FORWARDING_FIELDS,
+ MAVEN_FORWARDING_FIELDS,
+} 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';
+import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update';
+import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
+
+import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
+import ForwardingSettings from '~/packages_and_registries/settings/group/components/forwarding_settings.vue';
+
+export default {
+ name: 'PackageForwardingSettings',
+ i18n: {
+ PACKAGE_FORWARDING_FORM_BUTTON,
+ PACKAGE_FORWARDING_SETTINGS_HEADER,
+ PACKAGE_FORWARDING_SETTINGS_DESCRIPTION,
+ },
+ components: {
+ ForwardingSettings,
+ GlButton,
+ SettingsBlock,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ inject: ['groupPath'],
+ props: {
+ forwardSettings: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ mutationLoading: false,
+ workingCopy: { ...this.forwardSettings },
+ };
+ },
+ computed: {
+ packageForwardingFields() {
+ const fields = PACKAGE_FORWARDING_FIELDS;
+
+ if (this.glFeatures.mavenCentralRequestForwarding) {
+ return fields.concat(MAVEN_FORWARDING_FIELDS);
+ }
+
+ return fields;
+ },
+ isEdited() {
+ return !isEqual(this.forwardSettings, this.workingCopy);
+ },
+ isDisabled() {
+ return !this.isEdited || this.mutationLoading;
+ },
+ npmMutation() {
+ if (this.workingCopy.npmPackageRequestsForwardingLocked) {
+ return {};
+ }
+
+ return {
+ npmPackageRequestsForwarding: this.workingCopy.npmPackageRequestsForwarding,
+ lockNpmPackageRequestsForwarding: this.workingCopy.lockNpmPackageRequestsForwarding,
+ };
+ },
+ pypiMutation() {
+ if (this.workingCopy.pypiPackageRequestsForwardingLocked) {
+ return {};
+ }
+
+ return {
+ pypiPackageRequestsForwarding: this.workingCopy.pypiPackageRequestsForwarding,
+ lockPypiPackageRequestsForwarding: this.workingCopy.lockPypiPackageRequestsForwarding,
+ };
+ },
+ mavenMutation() {
+ if (this.workingCopy.mavenPackageRequestsForwardingLocked) {
+ return {};
+ }
+
+ return {
+ mavenPackageRequestsForwarding: this.workingCopy.mavenPackageRequestsForwarding,
+ lockMavenPackageRequestsForwarding: this.workingCopy.lockMavenPackageRequestsForwarding,
+ };
+ },
+ mutationVariables() {
+ return {
+ ...this.npmMutation,
+ ...this.pypiMutation,
+ ...this.mavenMutation,
+ };
+ },
+ },
+ watch: {
+ forwardSettings(newValue) {
+ this.workingCopy = { ...newValue };
+ },
+ },
+ methods: {
+ isForwardingFieldsDisabled(fields) {
+ const isLocked = fields?.modelNames?.isLocked;
+
+ return this.mutationLoading || this.workingCopy[isLocked];
+ },
+ forwardingFieldsForwarding(fields) {
+ const forwarding = fields?.modelNames?.forwarding;
+
+ return this.workingCopy[forwarding];
+ },
+ forwardingFieldsLockForwarding(fields) {
+ const lockForwarding = fields?.modelNames?.lockForwarding;
+
+ return this.workingCopy[lockForwarding];
+ },
+ async submit() {
+ this.mutationLoading = true;
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: updateNamespacePackageSettings,
+ variables: {
+ input: {
+ namespacePath: this.groupPath,
+ ...this.mutationVariables,
+ },
+ },
+ update: updateGroupPackageSettings(this.groupPath),
+ optimisticResponse: updateGroupPackagesSettingsOptimisticResponse({
+ ...this.forwardSettings,
+ ...this.mutationVariables,
+ }),
+ });
+
+ if (data.updateNamespacePackageSettings?.errors?.length > 0) {
+ throw new Error();
+ } else {
+ this.$emit('success');
+ }
+ } catch {
+ this.$emit('error');
+ } finally {
+ this.mutationLoading = false;
+ }
+ },
+ updateWorkingCopy(type, value) {
+ this.$set(this.workingCopy, type, value);
+ },
+ },
+};
+</script>
+
+<template>
+ <settings-block>
+ <template #title> {{ $options.i18n.PACKAGE_FORWARDING_SETTINGS_HEADER }}</template>
+ <template #description>
+ <span data-testid="description">
+ {{ $options.i18n.PACKAGE_FORWARDING_SETTINGS_DESCRIPTION }}
+ </span>
+ </template>
+ <template #default>
+ <form @submit.prevent="submit">
+ <forwarding-settings
+ v-for="forwardingFields in packageForwardingFields"
+ :key="forwardingFields.label"
+ :data-testid="forwardingFields.testid"
+ :disabled="isForwardingFieldsDisabled(forwardingFields)"
+ :forwarding="forwardingFieldsForwarding(forwardingFields)"
+ :label="forwardingFields.label"
+ :lock-forwarding="forwardingFieldsLockForwarding(forwardingFields)"
+ :model-names="forwardingFields.modelNames"
+ @update="updateWorkingCopy"
+ />
+ <gl-button
+ type="submit"
+ :disabled="isDisabled"
+ :loading="mutationLoading"
+ category="primary"
+ variant="confirm"
+ class="js-no-auto-disable gl-mr-4"
+ >
+ {{ $options.i18n.PACKAGE_FORWARDING_FORM_BUTTON }}
+ </gl-button>
+ </form>
+ </template>
+ </settings-block>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
index 2dd6d3f76f6..c93cd7f7d78 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -7,6 +7,8 @@ export const PACKAGE_SETTINGS_DESCRIPTION = s__(
);
export const PACKAGE_FORMATS_TABLE_HEADER = s__('PackageRegistry|Package formats');
export const MAVEN_PACKAGE_FORMAT = s__('PackageRegistry|Maven');
+export const NPM_PACKAGE_FORMAT = s__('PackageRegistry|npm');
+export const PYPI_PACKAGE_FORMAT = s__('PackageRegistry|PyPI');
export const GENERIC_PACKAGE_FORMAT = s__('PackageRegistry|Generic');
export const DUPLICATES_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates');
@@ -15,11 +17,65 @@ export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__(
'PackageRegistry|Publish packages if their name or version matches this regex.',
);
+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.',
+);
+export const PACKAGE_FORWARDING_CHECKBOX_LABEL = s__(
+ `PackageRegistry|Forward %{packageType} package requests`,
+);
+export const PACKAGE_FORWARDING_ENFORCE_LABEL = s__(
+ `PackageRegistry|Enforce %{packageType} setting for all subgroups`,
+);
+
+const MAVEN_PACKAGE_REQUESTS_FORWARDING = 'mavenPackageRequestsForwarding';
+const LOCK_MAVEN_PACKAGE_REQUESTS_FORWARDING = 'lockMavenPackageRequestsForwarding';
+const MAVEN_PACKAGE_REQUESTS_FORWARDING_LOCKED = 'mavenPackageRequestsForwardingLocked';
+const NPM_PACKAGE_REQUESTS_FORWARDING = 'npmPackageRequestsForwarding';
+const LOCK_NPM_PACKAGE_REQUESTS_FORWARDING = 'lockNpmPackageRequestsForwarding';
+const NPM_PACKAGE_REQUESTS_FORWARDING_LOCKED = 'npmPackageRequestsForwardingLocked';
+const PYPI_PACKAGE_REQUESTS_FORWARDING = 'pypiPackageRequestsForwarding';
+const LOCK_PYPI_PACKAGE_REQUESTS_FORWARDING = 'lockPypiPackageRequestsForwarding';
+const PYPI_PACKAGE_REQUESTS_FORWARDING_LOCKED = 'pypiPackageRequestsForwardingLocked';
+
+export const PACKAGE_FORWARDING_FORM_BUTTON = __('Save changes');
+
export const DEPENDENCY_PROXY_HEADER = s__('DependencyProxy|Dependency Proxy');
export const DEPENDENCY_PROXY_DESCRIPTION = s__(
'DependencyProxy|Enable the Dependency Proxy and settings for clearing the cache.',
);
+export const PACKAGE_FORWARDING_FIELDS = [
+ {
+ label: NPM_PACKAGE_FORMAT,
+ testid: 'npm',
+ modelNames: {
+ forwarding: NPM_PACKAGE_REQUESTS_FORWARDING,
+ lockForwarding: LOCK_NPM_PACKAGE_REQUESTS_FORWARDING,
+ isLocked: NPM_PACKAGE_REQUESTS_FORWARDING_LOCKED,
+ },
+ },
+ {
+ label: PYPI_PACKAGE_FORMAT,
+ testid: 'pypi',
+ modelNames: {
+ forwarding: PYPI_PACKAGE_REQUESTS_FORWARDING,
+ lockForwarding: LOCK_PYPI_PACKAGE_REQUESTS_FORWARDING,
+ isLocked: PYPI_PACKAGE_REQUESTS_FORWARDING_LOCKED,
+ },
+ },
+];
+
+export const MAVEN_FORWARDING_FIELDS = {
+ label: MAVEN_PACKAGE_FORMAT,
+ testid: 'maven',
+ modelNames: {
+ forwarding: MAVEN_PACKAGE_REQUESTS_FORWARDING,
+ lockForwarding: LOCK_MAVEN_PACKAGE_REQUESTS_FORWARDING,
+ isLocked: MAVEN_PACKAGE_REQUESTS_FORWARDING_LOCKED,
+ },
+};
+
// Parameters
export const PACKAGES_DOCS_PATH = helpPagePath('user/packages/index');
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/fragments/package_settings_fields.fragment.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/fragments/package_settings_fields.fragment.graphql
new file mode 100644
index 00000000000..267e40263f2
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/fragments/package_settings_fields.fragment.graphql
@@ -0,0 +1,15 @@
+fragment PackageSettingsFields on PackageSettings {
+ mavenDuplicatesAllowed
+ mavenDuplicateExceptionRegex
+ genericDuplicatesAllowed
+ genericDuplicateExceptionRegex
+ mavenPackageRequestsForwarding
+ lockMavenPackageRequestsForwarding
+ mavenPackageRequestsForwardingLocked
+ npmPackageRequestsForwarding
+ lockNpmPackageRequestsForwarding
+ npmPackageRequestsForwardingLocked
+ pypiPackageRequestsForwarding
+ lockPypiPackageRequestsForwarding
+ pypiPackageRequestsForwardingLocked
+}
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql
index 5c245ff9453..5558cb66f42 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql
@@ -1,10 +1,9 @@
+#import "~/packages_and_registries/settings/group/graphql/fragments/package_settings_fields.fragment.graphql"
+
mutation updateNamespacePackageSettings($input: UpdateNamespacePackageSettingsInput!) {
updateNamespacePackageSettings(input: $input) {
packageSettings {
- mavenDuplicatesAllowed
- mavenDuplicateExceptionRegex
- genericDuplicatesAllowed
- genericDuplicateExceptionRegex
+ ...PackageSettingsFields
}
errors
}
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_package_forwarding_settings.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_package_forwarding_settings.mutation.graphql
new file mode 100644
index 00000000000..e5e31f03a7d
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_package_forwarding_settings.mutation.graphql
@@ -0,0 +1,16 @@
+mutation updatePackageForwardingSettings($input: UpdateNamespacePackageSettingsInput!) {
+ updateNamespacePackageSettings(input: $input) {
+ packageSettings {
+ mavenPackageRequestsForwarding
+ lockMavenPackageRequestsForwarding
+ mavenPackageRequestsForwardingLocked
+ npmPackageRequestsForwarding
+ lockNpmPackageRequestsForwarding
+ npmPackageRequestsForwardingLocked
+ pypiPackageRequestsForwarding
+ lockPypiPackageRequestsForwarding
+ pypiPackageRequestsForwardingLocked
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
index 404d9d26d49..82a282d6d81 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
@@ -1,3 +1,5 @@
+#import "~/packages_and_registries/settings/group/graphql/fragments/package_settings_fields.fragment.graphql"
+
query getGroupPackagesSettings($fullPath: ID!) {
group(fullPath: $fullPath) {
id
@@ -9,10 +11,7 @@ query getGroupPackagesSettings($fullPath: ID!) {
enabled
}
packageSettings {
- mavenDuplicatesAllowed
- mavenDuplicateExceptionRegex
- genericDuplicatesAllowed
- genericDuplicateExceptionRegex
+ ...PackageSettingsFields
}
}
}
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/delete_package_modal.vue b/app/assets/javascripts/packages_and_registries/shared/components/delete_package_modal.vue
new file mode 100644
index 00000000000..b66b0b3548d
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/shared/components/delete_package_modal.vue
@@ -0,0 +1,83 @@
+<script>
+import { GlModal, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import {
+ DELETE_PACKAGE_MODAL_CONTENT_MESSAGE,
+ DELETE_PACKAGE_MODAL_TITLE,
+ DELETE_PACKAGE_MODAL_ACTION,
+} from '~/packages_and_registries/shared/constants';
+import { TRACK_CATEGORY } from '~/packages_and_registries/infrastructure_registry/shared/constants';
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ },
+ props: {
+ itemToBeDeleted: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ isModalVisible() {
+ return Boolean(this.itemToBeDeleted);
+ },
+ deletePackageName() {
+ return this.itemToBeDeleted?.name ?? '';
+ },
+ deleteModalActionPrimaryProps() {
+ return {
+ text: this.$options.i18n.modalAction,
+ attributes: {
+ variant: 'danger',
+ },
+ };
+ },
+ deleteModalActionCancelProps() {
+ return {
+ text: __('Cancel'),
+ };
+ },
+ tracking() {
+ return {
+ category: TRACK_CATEGORY,
+ };
+ },
+ },
+ methods: {
+ deleteItemConfirmation() {
+ this.$emit('ok');
+ },
+ onChangeModalVisibility(isVisible) {
+ if (!isVisible) this.$emit('cancel');
+ },
+ },
+ i18n: {
+ modalTitle: DELETE_PACKAGE_MODAL_TITLE,
+ modalDescription: DELETE_PACKAGE_MODAL_CONTENT_MESSAGE,
+ modalAction: DELETE_PACKAGE_MODAL_ACTION,
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ :visible="isModalVisible"
+ size="sm"
+ modal-id="confirm-delete-package"
+ :title="$options.i18n.modalTitle"
+ :action-primary="deleteModalActionPrimaryProps"
+ :action-cancel="deleteModalActionCancelProps"
+ @ok="deleteItemConfirmation"
+ @change="onChangeModalVisibility"
+ >
+ <template #modal-title>{{ $options.i18n.modalTitle }}</template>
+ <gl-sprintf :message="$options.i18n.modalDescription">
+ <template #name>
+ <strong>{{ deletePackageName }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
index 7fd440d0b27..f3ce967b756 100644
--- a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
+++ b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
@@ -39,6 +39,12 @@ export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__(
'PackageRegistry|Package asset deleted successfully',
);
+export const DELETE_PACKAGE_MODAL_CONTENT_MESSAGE = s__(
+ 'PackageRegistry|You are about to delete %{name}, are you sure?',
+);
+export const DELETE_PACKAGE_MODAL_TITLE = s__('PackageRegistry|Delete package');
+export const DELETE_PACKAGE_MODAL_ACTION = s__('PackageRegistry|Permanently delete');
+
export const PACKAGE_ERROR_STATUS = 'error';
export const PACKAGE_DEFAULT_STATUS = 'default';
export const PACKAGE_HIDDEN_STATUS = 'hidden';
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 ccb449f96e1..b68148e5461 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
@@ -44,6 +44,7 @@ export default {
'signupEnabled',
'requireAdminApprovalAfterUserSignup',
'sendUserConfirmationEmail',
+ 'emailConfirmationSetting',
'minimumPasswordLength',
'minimumPasswordLengthMin',
'minimumPasswordLengthMax',
@@ -58,6 +59,8 @@ export default {
'emailRestrictions',
'afterSignUpText',
'pendingUserCount',
+ 'projectSharingHelpLink',
+ 'groupSharingHelpLink',
],
data() {
return {
@@ -66,6 +69,7 @@ export default {
signupEnabled: this.signupEnabled,
requireAdminApproval: this.requireAdminApprovalAfterUserSignup,
sendConfirmationEmail: this.sendUserConfirmationEmail,
+ emailConfirmationSetting: this.emailConfirmationSetting,
minimumPasswordLength: this.minimumPasswordLength,
minimumPasswordLengthMin: this.minimumPasswordLengthMin,
minimumPasswordLengthMax: this.minimumPasswordLengthMax,
@@ -81,6 +85,8 @@ export default {
supportedSyntaxLinkUrl: this.supportedSyntaxLinkUrl,
emailRestrictions: this.emailRestrictions,
afterSignUpText: this.afterSignUpText,
+ projectSharingHelpLink: this.projectSharingHelpLink,
+ groupSharingHelpLink: this.groupSharingHelpLink,
},
};
},
@@ -199,6 +205,15 @@ export default {
signupEnabledLabel: s__('ApplicationSettings|Sign-up enabled'),
requireAdminApprovalLabel: s__('ApplicationSettings|Require admin approval for new sign-ups'),
sendConfirmationEmailLabel: s__('ApplicationSettings|Send confirmation email on sign-up'),
+ emailConfirmationSettingsLabel: s__('ApplicationSettings|Email confirmation settings'),
+ emailConfirmationSettingsOffLabel: s__('ApplicationSettings|Off'),
+ emailConfirmationSettingsOffHelpText: s__(
+ 'ApplicationSettings|New users can sign up without confirming their email address.',
+ ),
+ 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.',
+ ),
minimumPasswordLengthLabel: s__(
'ApplicationSettings|Minimum password length (number of characters)',
),
@@ -208,7 +223,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 unlimited.',
+ '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}.',
),
domainDenyListGroupLabel: s__('ApplicationSettings|Domain denylist'),
domainDenyListLabel: s__('ApplicationSettings|Enable domain denylist for sign-ups'),
@@ -276,9 +291,28 @@ export default {
:label="$options.i18n.sendConfirmationEmailLabel"
/>
+ <gl-form-group :label="$options.i18n.emailConfirmationSettingsLabel">
+ <gl-form-radio-group
+ 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-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"
@@ -286,6 +320,17 @@ 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/jobs/index/components/constants.js b/app/assets/javascripts/pages/admin/jobs/index/components/constants.js
new file mode 100644
index 00000000000..9e2d464bc4d
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/index/components/constants.js
@@ -0,0 +1,11 @@
+import { s__, __ } from '~/locale';
+
+export const STOP_JOBS_MODAL_ID = 'stop-jobs-modal';
+export const STOP_JOBS_MODAL_TITLE = s__('AdminArea|Stop all jobs?');
+export const STOP_JOBS_BUTTON_TEXT = s__('AdminArea|Stop all jobs');
+export const CANCEL_TEXT = __('Cancel');
+export const STOP_JOBS_FAILED_TEXT = s__('AdminArea|Stopping jobs failed');
+export const PRIMARY_ACTION_TEXT = s__('AdminArea|Stop jobs');
+export const STOP_JOBS_WARNING = s__(
+ 'AdminArea|You’re about to stop all jobs. This will halt all current jobs that are running.',
+);
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue
index 4f42ef2892d..b608b3b9492 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue
+++ b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue
@@ -3,7 +3,14 @@ import { GlModal } from '@gitlab/ui';
import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { redirectTo } from '~/lib/utils/url_utility';
-import { __, s__ } from '~/locale';
+import {
+ CANCEL_TEXT,
+ STOP_JOBS_MODAL_ID,
+ STOP_JOBS_FAILED_TEXT,
+ STOP_JOBS_MODAL_TITLE,
+ STOP_JOBS_WARNING,
+ PRIMARY_ACTION_TEXT,
+} from './constants';
export default {
components: {
@@ -15,13 +22,6 @@ export default {
required: true,
},
},
- computed: {
- text() {
- return s__(
- 'AdminArea|You’re about to stop all jobs. This will halt all current jobs that are running.',
- );
- },
- },
methods: {
onSubmit() {
return axios
@@ -32,30 +32,33 @@ export default {
})
.catch((error) => {
createAlert({
- message: s__('AdminArea|Stopping jobs failed'),
+ message: STOP_JOBS_FAILED_TEXT,
});
throw error;
});
},
},
primaryAction: {
- text: s__('AdminArea|Stop jobs'),
+ text: PRIMARY_ACTION_TEXT,
attributes: [{ variant: 'danger' }],
},
cancelAction: {
- text: __('Cancel'),
+ text: CANCEL_TEXT,
},
+ STOP_JOBS_WARNING,
+ STOP_JOBS_MODAL_ID,
+ STOP_JOBS_MODAL_TITLE,
};
</script>
<template>
<gl-modal
- modal-id="stop-jobs-modal"
+ :modal-id="$options.STOP_JOBS_MODAL_ID"
:action-primary="$options.primaryAction"
:action-cancel="$options.cancelAction"
@primary="onSubmit"
>
- <template #modal-title>{{ s__('AdminArea|Stop all jobs?') }}</template>
- {{ text }}
+ <template #modal-title>{{ $options.STOP_JOBS_MODAL_TITLE }}</template>
+ {{ $options.STOP_JOBS_WARNING }}
</gl-modal>
</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js
index 4cd312b403c..c82b186f671 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/index.js
+++ b/app/assets/javascripts/pages/admin/jobs/index/index.js
@@ -1,29 +1,29 @@
import Vue from 'vue';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import Translate from '~/vue_shared/translate';
+import { STOP_JOBS_MODAL_ID } from './components/constants';
import StopJobsModal from './components/stop_jobs_modal.vue';
Vue.use(Translate);
function initJobs() {
const buttonId = 'js-stop-jobs-button';
- const modalId = 'stop-jobs-modal';
const stopJobsButton = document.getElementById(buttonId);
if (stopJobsButton) {
// eslint-disable-next-line no-new
new Vue({
- el: `#js-${modalId}`,
+ el: `#js-${STOP_JOBS_MODAL_ID}`,
components: {
StopJobsModal,
},
mounted() {
stopJobsButton.classList.remove('disabled');
stopJobsButton.addEventListener('click', () => {
- this.$root.$emit(BV_SHOW_MODAL, modalId, `#${buttonId}`);
+ this.$root.$emit(BV_SHOW_MODAL, STOP_JOBS_MODAL_ID, `#${buttonId}`);
});
},
render(createElement) {
- return createElement(modalId, {
+ return createElement(STOP_JOBS_MODAL_ID, {
props: {
url: stopJobsButton.dataset.url,
},
diff --git a/app/assets/javascripts/pages/admin/runners/edit/index.js b/app/assets/javascripts/pages/admin/runners/edit/index.js
index 03d31f49a99..dcce2a8dafd 100644
--- a/app/assets/javascripts/pages/admin/runners/edit/index.js
+++ b/app/assets/javascripts/pages/admin/runners/edit/index.js
@@ -1,3 +1,3 @@
-import { initRunnerEdit } from '~/runner/runner_edit';
+import { initRunnerEdit } from '~/ci/runner/runner_edit';
initRunnerEdit('#js-admin-runner-edit');
diff --git a/app/assets/javascripts/pages/admin/runners/index/index.js b/app/assets/javascripts/pages/admin/runners/index/index.js
index f83111b6385..bc7b1421b31 100644
--- a/app/assets/javascripts/pages/admin/runners/index/index.js
+++ b/app/assets/javascripts/pages/admin/runners/index/index.js
@@ -1,3 +1,3 @@
-import { initAdminRunners } from '~/runner/admin_runners';
+import { initAdminRunners } from '~/ci/runner/admin_runners';
initAdminRunners();
diff --git a/app/assets/javascripts/pages/admin/runners/show/index.js b/app/assets/javascripts/pages/admin/runners/show/index.js
index f76f3a2430d..fd38abf221a 100644
--- a/app/assets/javascripts/pages/admin/runners/show/index.js
+++ b/app/assets/javascripts/pages/admin/runners/show/index.js
@@ -1,3 +1,3 @@
-import { initAdminRunnerShow } from '~/runner/admin_runner_show';
+import { initAdminRunnerShow } from '~/ci/runner/admin_runner_show';
initAdminRunnerShow();
diff --git a/app/assets/javascripts/pages/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js
index d0903ad53bc..08c247a498b 100644
--- a/app/assets/javascripts/pages/dashboard/issues/index.js
+++ b/app/assets/javascripts/pages/dashboard/issues/index.js
@@ -1,4 +1,5 @@
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
+import { mountIssuesDashboardApp } from '~/issues/dashboard';
import initManualOrdering from '~/issues/manual_ordering';
import { FILTERED_SEARCH } from '~/filtered_search/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
@@ -12,3 +13,5 @@ initFilteredSearch({
projectSelect();
initManualOrdering();
+
+mountIssuesDashboardApp();
diff --git a/app/assets/javascripts/pages/groups/observability/dashboards/index.js b/app/assets/javascripts/pages/groups/observability/dashboards/index.js
new file mode 100644
index 00000000000..c3b6ce6f99f
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/observability/dashboards/index.js
@@ -0,0 +1,3 @@
+import ObservabilityApp from '~/observability';
+
+ObservabilityApp();
diff --git a/app/assets/javascripts/pages/groups/observability/explore/index.js b/app/assets/javascripts/pages/groups/observability/explore/index.js
new file mode 100644
index 00000000000..c3b6ce6f99f
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/observability/explore/index.js
@@ -0,0 +1,3 @@
+import ObservabilityApp from '~/observability';
+
+ObservabilityApp();
diff --git a/app/assets/javascripts/pages/groups/observability/manage/index.js b/app/assets/javascripts/pages/groups/observability/manage/index.js
new file mode 100644
index 00000000000..c3b6ce6f99f
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/observability/manage/index.js
@@ -0,0 +1,3 @@
+import ObservabilityApp from '~/observability';
+
+ObservabilityApp();
diff --git a/app/assets/javascripts/pages/groups/runners/edit/index.js b/app/assets/javascripts/pages/groups/runners/edit/index.js
index febb0026b67..08ae57866e2 100644
--- a/app/assets/javascripts/pages/groups/runners/edit/index.js
+++ b/app/assets/javascripts/pages/groups/runners/edit/index.js
@@ -1,3 +1,3 @@
-import { initRunnerEdit } from '~/runner/runner_edit';
+import { initRunnerEdit } from '~/ci/runner/runner_edit';
initRunnerEdit('#js-group-runner-edit');
diff --git a/app/assets/javascripts/pages/groups/runners/index/index.js b/app/assets/javascripts/pages/groups/runners/index/index.js
index ca1a6bdab75..1cc07d1069a 100644
--- a/app/assets/javascripts/pages/groups/runners/index/index.js
+++ b/app/assets/javascripts/pages/groups/runners/index/index.js
@@ -1,3 +1,3 @@
-import { initGroupRunners } from '~/runner/group_runners';
+import { initGroupRunners } from '~/ci/runner/group_runners';
initGroupRunners();
diff --git a/app/assets/javascripts/pages/groups/runners/show/index.js b/app/assets/javascripts/pages/groups/runners/show/index.js
index c59e3b80dc1..755e38004ad 100644
--- a/app/assets/javascripts/pages/groups/runners/show/index.js
+++ b/app/assets/javascripts/pages/groups/runners/show/index.js
@@ -1,3 +1,3 @@
-import { initGroupRunnerShow } from '~/runner/group_runner_show';
+import { initGroupRunnerShow } from '~/ci/runner/group_runner_show';
initGroupRunnerShow();
diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js
index 2aec0617b5a..52124865bcc 100644
--- a/app/assets/javascripts/pages/groups/shared/group_details.js
+++ b/app/assets/javascripts/pages/groups/shared/group_details.js
@@ -1,23 +1,12 @@
/* eslint-disable no-new */
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
-import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants';
import initInviteMembersBanner from '~/groups/init_invite_members_banner';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
-import { getPagePath, getDashPath } from '~/lib/utils/common_utils';
import initNotificationsDropdown from '~/notifications';
import ProjectsList from '~/projects_list';
-import GroupTabs from './group_tabs';
-export default function initGroupDetails(actionName = 'show') {
- const loadableActions = [ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED];
- const dashPath = getDashPath();
- let action = loadableActions.includes(dashPath) ? dashPath : getPagePath(1);
- if (actionName && action === actionName) {
- action = 'show'; // 'show' resets GroupTabs to default action through base class
- }
-
- new GroupTabs({ parentEl: '.groups-listing', action });
+export default function initGroupDetails() {
new ShortcutsNavigation();
initNotificationsDropdown();
diff --git a/app/assets/javascripts/pages/groups/shared/group_tabs.js b/app/assets/javascripts/pages/groups/shared/group_tabs.js
deleted file mode 100644
index 73d810007dc..00000000000
--- a/app/assets/javascripts/pages/groups/shared/group_tabs.js
+++ /dev/null
@@ -1,136 +0,0 @@
-import $ from 'jquery';
-import createGroupTree from '~/groups';
-import {
- ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
- ACTIVE_TAB_SHARED,
- ACTIVE_TAB_ARCHIVED,
- CONTENT_LIST_CLASS,
- GROUPS_LIST_HOLDER_CLASS,
- GROUPS_FILTER_FORM_CLASS,
-} from '~/groups/constants';
-import GroupFilterableList from '~/groups/groups_filterable_list';
-import { removeParams } from '~/lib/utils/url_utility';
-import UserTabs from '~/pages/users/user_tabs';
-
-export default class GroupTabs extends UserTabs {
- constructor({ defaultAction = 'subgroups_and_projects', action, parentEl }) {
- super({ defaultAction, action, parentEl });
- }
-
- bindEvents() {
- this.$parentEl
- .off('shown.bs.tab', '.nav-links a[data-toggle="tab"]')
- .on('shown.bs.tab', '.nav-links a[data-toggle="tab"]', (event) => this.tabShown(event));
- }
-
- tabShown(event) {
- const $target = $(event.target);
- const action = $target.data('action') || $target.data('targetSection');
- const source = $target.attr('href') || $target.data('targetPath');
-
- document.querySelector(GROUPS_FILTER_FORM_CLASS).action = source;
-
- this.setTab(action);
- return this.setCurrentAction(source);
- }
-
- setTab(action) {
- const loadableActions = [
- ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
- ACTIVE_TAB_SHARED,
- ACTIVE_TAB_ARCHIVED,
- ];
- this.enableSearchBar(action);
- this.action = action;
-
- if (this.loaded[action]) {
- return;
- }
-
- if (loadableActions.includes(action)) {
- this.cleanFilterState();
- this.loadTab(action);
- }
- }
-
- loadTab(action) {
- const elId = `js-groups-${action}-tree`;
- const endpoint = this.getEndpoint(action);
-
- this.toggleLoading(true);
-
- createGroupTree(elId, endpoint, action);
- this.loaded[action] = true;
-
- this.toggleLoading(false);
- }
-
- getEndpoint(action) {
- const { endpointsDefault, endpointsShared } = this.$parentEl.data();
- let endpoint;
-
- switch (action) {
- case ACTIVE_TAB_ARCHIVED:
- endpoint = `${endpointsDefault}?archived=only`;
- break;
- case ACTIVE_TAB_SHARED:
- endpoint = endpointsShared;
- break;
- default:
- // ACTIVE_TAB_SUBGROUPS_AND_PROJECTS
- endpoint = endpointsDefault;
- break;
- }
-
- return endpoint;
- }
-
- enableSearchBar(action) {
- const containerEl = document.getElementById(action);
- const form = document.querySelector(GROUPS_FILTER_FORM_CLASS);
- const filter = form.querySelector('.js-groups-list-filter');
- const holder = containerEl.querySelector(GROUPS_LIST_HOLDER_CLASS);
- const dataEl = containerEl.querySelector(CONTENT_LIST_CLASS);
- const endpoint = this.getEndpoint(action);
-
- if (!dataEl) {
- return;
- }
-
- const { dataset } = dataEl;
- const opts = {
- form,
- filter,
- holder,
- filterEndpoint: endpoint || dataset.endpoint,
- pagePath: null,
- dropdownSel: '.js-group-filter-dropdown-wrap',
- filterInputField: 'filter',
- action,
- };
-
- if (!this.loaded[action]) {
- const filterableList = new GroupFilterableList(opts);
- filterableList.initSearch();
- }
- }
-
- cleanFilterState() {
- const values = Object.values(this.loaded);
- const loadedTabs = values.filter((e) => e === true);
-
- if (!loadedTabs.length) {
- return;
- }
-
- const newState = removeParams(['page'], window.location.search);
-
- window.history.replaceState(
- {
- url: newState,
- },
- document.title,
- newState,
- );
- }
-}
diff --git a/app/assets/javascripts/pages/profiles/init_timezone_dropdown.js b/app/assets/javascripts/pages/profiles/init_timezone_dropdown.js
index 80b911493a8..91d5013c67d 100644
--- a/app/assets/javascripts/pages/profiles/init_timezone_dropdown.js
+++ b/app/assets/javascripts/pages/profiles/init_timezone_dropdown.js
@@ -8,7 +8,7 @@ export const initTimezoneDropdown = () => {
return null;
}
- const { timezoneData, initialValue } = el.dataset;
+ const { timezoneData, initialValue, name } = el.dataset;
const timezones = JSON.parse(timezoneData);
const timezoneDropdown = new Vue({
@@ -23,7 +23,7 @@ export const initTimezoneDropdown = () => {
props: {
value: this.value,
timezoneData: timezones,
- name: 'user[timezone]',
+ name,
},
class: 'gl-md-form-input-lg',
});
diff --git a/app/assets/javascripts/pages/projects/artifacts/index.js b/app/assets/javascripts/pages/projects/artifacts/index.js
new file mode 100644
index 00000000000..4aa9b225790
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/artifacts/index.js
@@ -0,0 +1,3 @@
+import { initArtifactsTable } from '~/artifacts/index';
+
+initArtifactsTable();
diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js
index f3530b46845..ac5e0b28dd1 100644
--- a/app/assets/javascripts/pages/projects/branches/index/index.js
+++ b/app/assets/javascripts/pages/projects/branches/index/index.js
@@ -3,6 +3,7 @@ import BranchSortDropdown from '~/branches/branch_sort_dropdown';
import initDiverganceGraph from '~/branches/divergence_graph';
import initDeleteBranchButton from '~/branches/init_delete_branch_button';
import initDeleteBranchModal from '~/branches/init_delete_branch_modal';
+import initDeleteMergedBranches from '~/branches/init_delete_merged_branches';
const { divergingCountsEndpoint, defaultBranch } = document.querySelector(
'.js-branch-list',
@@ -11,6 +12,7 @@ const { divergingCountsEndpoint, defaultBranch } = document.querySelector(
initDiverganceGraph(divergingCountsEndpoint, defaultBranch);
BranchSortDropdown();
initDeprecatedRemoveRowBehavior();
+initDeleteMergedBranches();
document
.querySelectorAll('.js-delete-branch-button')
diff --git a/app/assets/javascripts/pages/projects/hooks/index.js b/app/assets/javascripts/pages/projects/hooks/index.js
index ed476d25f8b..9e559354205 100644
--- a/app/assets/javascripts/pages/projects/hooks/index.js
+++ b/app/assets/javascripts/pages/projects/hooks/index.js
@@ -1,5 +1,7 @@
import initSearchSettings from '~/search_settings';
import initWebhookForm from '~/webhooks';
+import { initPushEventsEditForm } from '~/webhooks/webhook';
initSearchSettings();
initWebhookForm();
+initPushEventsEditForm();
diff --git a/app/assets/javascripts/pages/projects/init_blob.js b/app/assets/javascripts/pages/projects/init_blob.js
index f37a2987685..097b2f33aa9 100644
--- a/app/assets/javascripts/pages/projects/init_blob.js
+++ b/app/assets/javascripts/pages/projects/init_blob.js
@@ -3,7 +3,6 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import BlobForkSuggestion from '~/blob/blob_fork_suggestion';
import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater';
import LineHighlighter from '~/blob/line_highlighter';
-import initBlobBundle from '~/blob_edit/blob_bundle';
export default () => {
new LineHighlighter(); // eslint-disable-line no-new
@@ -35,6 +34,4 @@ export default () => {
suggestionSections: document.querySelectorAll('.js-file-fork-suggestion-section'),
actionTextPieces: document.querySelectorAll('.js-file-fork-suggestion-section-action'),
}).init();
-
- initBlobBundle();
};
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
index 4eab0cccb06..3717d8027c4 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
@@ -86,6 +86,7 @@ export default {
:target="openInNewTab ? '_blank' : '_self'"
:href="value.url"
data-testid="uncompleted-learn-gitlab-link"
+ data-qa-selector="uncompleted_learn_gitlab_link"
data-track-action="click_link"
:data-track-label="actionLabelValue('trackLabel')"
>{{ actionLabelValue('title') }}</gl-link
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 ebf7c266482..42fa306d226 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
@@ -6,8 +6,8 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import Diff from '~/diff';
import GLForm from '~/gl_form';
import LabelsSelect from '~/labels/labels_select';
-import MilestoneSelect from '~/milestones/milestone_select';
import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
+import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar';
export default () => {
new Diff();
@@ -15,8 +15,8 @@ export default () => {
new GLForm($('.merge-request-form'));
new IssuableForm($('.merge-request-form'));
new LabelsSelect();
- new MilestoneSelect();
new IssuableTemplateSelectors({
warnTemplateOverride: true,
});
+ mountMilestoneDropdown('[name="merge_request[milestone_id]"]');
};
diff --git a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
new file mode 100644
index 00000000000..0a9d9f4c987
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import ShowExperiment from '~/ml/experiment_tracking/components/experiment.vue';
+
+const initShowExperiment = () => {
+ const element = document.querySelector('#js-show-ml-experiment');
+ if (!element) {
+ return;
+ }
+
+ const container = document.createElement('div');
+ element.appendChild(container);
+
+ const candidates = JSON.parse(element.dataset.candidates);
+ const metricNames = JSON.parse(element.dataset.metrics);
+ const paramNames = JSON.parse(element.dataset.params);
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: container,
+ provide: {
+ candidates,
+ metricNames,
+ paramNames,
+ },
+ render(h) {
+ return h(ShowExperiment);
+ },
+ });
+};
+
+initShowExperiment();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js
index 0edce2db0a3..e2a782bc5d8 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js
@@ -1,4 +1,4 @@
-import initPipelineSchedulesFormApp from '~/pipeline_schedules/mount_pipeline_schedules_form_app';
+import initPipelineSchedulesFormApp from '~/ci/pipeline_schedules/mount_pipeline_schedules_form_app';
import initForm from '../shared/init_form';
if (gon.features?.pipelineSchedulesVue) {
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
index 7d0930f6424..27610df482d 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
-import initPipelineSchedulesApp from '~/pipeline_schedules/mount_pipeline_schedules_app';
-import PipelineSchedulesTakeOwnershipModal from '~/pipeline_schedules/components/take_ownership_modal.vue';
+import initPipelineSchedulesApp from '~/ci/pipeline_schedules/mount_pipeline_schedules_app';
+import PipelineSchedulesTakeOwnershipModalLegacy from '~/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue';
import PipelineSchedulesCallout from '../shared/components/pipeline_schedules_callout.vue';
function initPipelineSchedulesCallout() {
@@ -58,7 +58,7 @@ function initTakeownershipModal() {
});
},
render(createElement) {
- return createElement(PipelineSchedulesTakeOwnershipModal, {
+ return createElement(PipelineSchedulesTakeOwnershipModalLegacy, {
props: {
ownershipUrl: this.url,
},
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js
index 06084fa729b..d8ba7bbd752 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js
@@ -1,4 +1,4 @@
-import initPipelineSchedulesFormApp from '~/pipeline_schedules/mount_pipeline_schedules_form_app';
+import initPipelineSchedulesFormApp from '~/ci/pipeline_schedules/mount_pipeline_schedules_form_app';
import initForm from '../shared/init_form';
if (gon.features?.pipelineSchedulesVue) {
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
index eae721771de..abd17efc498 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
@@ -6,8 +6,8 @@ import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list';
import GlFieldErrors from '~/gl_field_errors';
import Translate from '~/vue_shared/translate';
+import { initTimezoneDropdown } from '../../../profiles/init_timezone_dropdown';
import IntervalPatternInput from './components/interval_pattern_input.vue';
-import TimezoneDropdown from './components/timezone_dropdown';
Vue.use(Translate);
@@ -81,13 +81,6 @@ export default () => {
const formElement = document.getElementById('new-pipeline-schedule-form');
- gl.timezoneDropdown = new TimezoneDropdown({
- $dropdownEl: $('.js-timezone-dropdown'),
- $inputEl: $('#schedule_cron_timezone'),
- onSelectTimezone: () => {
- gl.pipelineScheduleFieldErrors.updateFormValidityState();
- },
- });
gl.pipelineScheduleFieldErrors = new GlFieldErrors(formElement);
initTargetRefDropdown();
@@ -97,3 +90,5 @@ export default () => {
formField: 'schedule',
});
};
+
+initTimezoneDropdown();
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 3e5c02bbf19..c37b4cc643a 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
@@ -41,6 +41,8 @@ export default {
featureFlagsHelpText: s__(
'ProjectSettings|Roll out new features without redeploying with feature flags.',
),
+ infrastructureLabel: s__('ProjectSettings|Infrastructure'),
+ infrastructureHelpText: s__('ProjectSettings|Configure your infrastructure.'),
monitorLabel: s__('ProjectSettings|Monitor'),
packagesHelpText: s__(
'ProjectSettings|Every project can have its own space to store its packages. Note: The Package Registry is always visible when a project is public.',
@@ -157,6 +159,11 @@ export default {
required: false,
default: '',
},
+ infrastructureHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
releasesHelpPath: {
type: String,
required: false,
@@ -245,6 +252,7 @@ export default {
operationsAccessLevel: featureAccessLevel.EVERYONE,
environmentsAccessLevel: featureAccessLevel.EVERYONE,
featureFlagsAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
+ infrastructureAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
releasesAccessLevel: featureAccessLevel.EVERYONE,
monitorAccessLevel: featureAccessLevel.EVERYONE,
containerRegistryAccessLevel: featureAccessLevel.EVERYONE,
@@ -433,6 +441,10 @@ export default {
featureAccessLevel.PROJECT_MEMBERS,
this.featureFlagsAccessLevel,
);
+ this.infrastructureAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.infrastructureAccessLevel,
+ );
this.releasesAccessLevel = Math.min(
featureAccessLevel.PROJECT_MEMBERS,
this.releasesAccessLevel,
@@ -981,6 +993,19 @@ export default {
name="project[project_feature_attributes][feature_flags_access_level]"
/>
</project-setting-row>
+ <project-setting-row
+ ref="infrastructure-settings"
+ :label="$options.i18n.infrastructureLabel"
+ :help-text="$options.i18n.infrastructureHelpText"
+ :help-path="infrastructureHelpPath"
+ >
+ <project-feature-setting
+ v-model="infrastructureAccessLevel"
+ :label="$options.i18n.infrastructureLabel"
+ :options="featureAccessLevelOptions"
+ name="project[project_feature_attributes][infrastructure_access_level]"
+ />
+ </project-setting-row>
</template>
<project-setting-row
ref="releases-settings"
diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index eff39a744ad..1de36f4a0fb 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -10,14 +10,6 @@ import { initUploadFileTrigger } from '~/projects/upload_file';
import initReadMore from '~/read_more';
// Project show page loads different overview content based on user preferences
-if (document.querySelector('.js-upload-blob-form')) {
- import(/* webpackChunkName: 'blobBundle' */ '~/blob_edit/blob_bundle')
- .then(({ initUploadForm }) => {
- initUploadForm();
- })
- .catch(() => {});
-}
-
if (document.getElementById('js-tree-list')) {
import(/* webpackChunkName: 'treeList' */ 'ee_else_ce/repository')
.then(({ default: initTree }) => {
diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js
index cf7162f477d..17c17014ece 100644
--- a/app/assets/javascripts/pages/projects/tree/show/index.js
+++ b/app/assets/javascripts/pages/projects/tree/show/index.js
@@ -1,10 +1,8 @@
import $ from 'jquery';
import initTree from 'ee_else_ce/repository';
-import initBlob from '~/blob_edit/blob_bundle';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import NewCommitForm from '~/new_commit_form';
new NewCommitForm($('.js-create-dir-form')); // eslint-disable-line no-new
-initBlob();
initTree();
new ShortcutsNavigation(); // eslint-disable-line no-new
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 7b9656de362..8e2f542aec0 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -343,7 +343,7 @@ export default {
:uploads-path="pageInfo.uploadsPath"
:enable-content-editor="isMarkdownFormat"
:enable-preview="isMarkdownFormat"
- :init-on-autofocus="pageInfo.persisted"
+ :autofocus="pageInfo.persisted"
:form-field-placeholder="$options.i18n.content.placeholder"
:form-field-aria-label="$options.i18n.content.label"
form-field-id="wiki_content"
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index 2580cbcb944..139da5dabbd 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -23,9 +23,9 @@ const PERSISTENT_USER_CALLOUTS = [
];
const initCallouts = () => {
- PERSISTENT_USER_CALLOUTS.forEach((calloutContainer) =>
- PersistentUserCallout.factory(document.querySelector(calloutContainer)),
- );
+ document
+ .querySelectorAll(PERSISTENT_USER_CALLOUTS)
+ .forEach((calloutContainer) => PersistentUserCallout.factory(calloutContainer));
};
export default initCallouts;
diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
index 4941f22230b..ed5466ff99c 100644
--- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -219,7 +219,6 @@ export default {
:empty-message="$options.i18n.empty.merge"
:keep-component-mounted="false"
:is-empty="isEmpty"
- :is-invalid="isInvalid"
:is-unavailable="isLintUnavailable"
:title="$options.i18n.tabMergedYaml"
lazy
diff --git a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules.vue b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules.vue
deleted file mode 100644
index 4a08a82275a..00000000000
--- a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules.vue
+++ /dev/null
@@ -1,134 +0,0 @@
-<script>
-import { GlAlert, GlLoadingIcon, GlModal } from '@gitlab/ui';
-import { s__, __ } from '~/locale';
-import deletePipelineScheduleMutation from '../graphql/mutations/delete_pipeline_schedule.mutation.graphql';
-import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql';
-import PipelineSchedulesTable from './table/pipeline_schedules_table.vue';
-
-export default {
- i18n: {
- schedulesFetchError: s__('PipelineSchedules|There was a problem fetching pipeline schedules.'),
- scheduleDeleteError: s__(
- 'PipelineSchedules|There was a problem deleting the pipeline schedule.',
- ),
- },
- modal: {
- id: 'delete-pipeline-schedule-modal',
- deleteConfirmation: s__(
- 'PipelineSchedules|Are you sure you want to delete this pipeline schedule?',
- ),
- actionPrimary: {
- text: s__('PipelineSchedules|Delete pipeline schedule'),
- attributes: [{ variant: 'danger' }],
- },
- actionCancel: {
- text: __('Cancel'),
- attributes: [],
- },
- },
- components: {
- GlAlert,
- GlLoadingIcon,
- GlModal,
- PipelineSchedulesTable,
- },
- inject: {
- fullPath: {
- default: '',
- },
- },
- apollo: {
- schedules: {
- query: getPipelineSchedulesQuery,
- variables() {
- return {
- projectPath: this.fullPath,
- };
- },
- update({ project }) {
- return project?.pipelineSchedules?.nodes || [];
- },
- error() {
- this.reportError(this.$options.i18n.schedulesFetchError);
- },
- },
- },
- data() {
- return {
- schedules: [],
- hasError: false,
- errorMessage: '',
- scheduleToDeleteId: null,
- showModal: false,
- };
- },
- computed: {
- isLoading() {
- return this.$apollo.queries.schedules.loading;
- },
- },
- methods: {
- reportError(error) {
- this.hasError = true;
- this.errorMessage = error;
- },
- showDeleteModal(id) {
- this.showModal = true;
- this.scheduleToDeleteId = id;
- },
- hideModal() {
- this.showModal = false;
- this.scheduleToDeleteId = null;
- },
- async deleteSchedule() {
- try {
- const {
- data: {
- pipelineScheduleDelete: { errors },
- },
- } = await this.$apollo.mutate({
- mutation: deletePipelineScheduleMutation,
- variables: { id: this.scheduleToDeleteId },
- });
-
- if (errors.length > 0) {
- throw new Error();
- } else {
- this.$apollo.queries.schedules.refetch();
- }
- } catch {
- this.reportError(this.$options.i18n.scheduleDeleteError);
- }
- },
- },
-};
-</script>
-
-<template>
- <div>
- <gl-alert v-if="hasError" class="gl-mb-2" variant="danger" @dismiss="hasError = false">
- {{ errorMessage }}
- </gl-alert>
-
- <gl-loading-icon v-if="isLoading" size="lg" />
-
- <!-- Tabs will be addressed in #371989 -->
-
- <template v-else>
- <pipeline-schedules-table :schedules="schedules" @showDeleteModal="showDeleteModal" />
-
- <gl-modal
- :visible="showModal"
- :title="$options.modal.actionPrimary.text"
- :modal-id="$options.modal.id"
- :action-primary="$options.modal.actionPrimary"
- :action-cancel="$options.modal.actionCancel"
- size="sm"
- @primary="deleteSchedule"
- @hide="hideModal"
- >
- {{ $options.modal.deleteConfirmation }}
- </gl-modal>
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue
index 475dd3bf36e..be12df68f76 100644
--- a/app/assets/javascripts/pipelines/components/dag/dag.vue
+++ b/app/assets/javascripts/pipelines/components/dag/dag.vue
@@ -29,7 +29,7 @@ export default {
dagDocPath: {
default: null,
},
- emptySvgPath: {
+ emptyDagSvgPath: {
default: '',
},
pipelineIid: {
@@ -213,7 +213,7 @@ export default {
/>
<gl-empty-state
v-else-if="hasNoDependentJobs"
- :svg-path="emptySvgPath"
+ :svg-path="emptyDagSvgPath"
:title="$options.emptyStateTexts.title"
>
<template #description>
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 f1c6c6633eb..ba150919e58 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
@@ -27,6 +27,7 @@ export default {
},
dropdownPopperOpts: {
placement: 'bottom',
+ positionFixed: true,
},
components: {
CiIcon,
@@ -136,25 +137,19 @@ export default {
:is-active="isDropdownOpen"
:size="24"
:status="stage.status"
- class="gl-align-items-center gl-border gl-display-inline-flex gl-z-index-1"
+ class="gl-display-inline-flex gl-align-items-center gl-border gl-z-index-1"
/>
</template>
- <div
- v-if="isLoading"
- class="gl-display-flex gl-justify-content-center gl-p-2"
- data-testid="pipeline-stage-loading-state"
- >
+ <div v-if="isLoading" class="gl--flex-center gl-p-2" data-testid="pipeline-stage-loading-state">
<gl-loading-icon size="sm" class="gl-mr-3" />
- <p class="gl-mb-0">{{ $options.i18n.loadingText }}</p>
+ <p class="gl-line-height-normal gl-mb-0">{{ $options.i18n.loadingText }}</p>
</div>
<ul
v-else
class="js-builds-dropdown-list scrollable-menu"
data-testid="mini-pipeline-graph-dropdown-menu-list"
>
- <div
- class="gl-align-items-center gl-border-b gl-display-flex gl-font-weight-bold gl-justify-content-center gl-pb-3"
- >
+ <div class="gl--flex-center gl-border-b gl-font-weight-bold gl-pb-3">
<span class="gl-mr-1">{{ $options.i18n.stage }}</span>
<span data-testid="pipeline-stage-dropdown-menu-title">{{ stageName }}</span>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
index 2a78636261b..3798863ae60 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
@@ -1,12 +1,13 @@
<script>
import { GlBadge, GlTabs, GlTab } from '@gitlab/ui';
import { __ } from '~/locale';
-import { failedJobsTabName, jobsTabName, needsTabName, testReportTabName } from '../constants';
-import PipelineGraphWrapper from './graph/graph_component_wrapper.vue';
-import Dag from './dag/dag.vue';
-import FailedJobsApp from './jobs/failed_jobs_app.vue';
-import JobsApp from './jobs/jobs_app.vue';
-import TestReports from './test_reports/test_reports.vue';
+import {
+ failedJobsTabName,
+ jobsTabName,
+ needsTabName,
+ pipelineTabName,
+ testReportTabName,
+} from '../constants';
export default {
i18n: {
@@ -19,20 +20,16 @@ export default {
},
},
tabNames: {
+ pipeline: pipelineTabName,
needs: needsTabName,
jobs: jobsTabName,
failures: failedJobsTabName,
tests: testReportTabName,
},
components: {
- Dag,
GlBadge,
GlTab,
GlTabs,
- JobsApp,
- FailedJobsApp,
- PipelineGraphWrapper,
- TestReports,
},
inject: [
'defaultTabValue',
@@ -41,14 +38,27 @@ export default {
'totalJobCount',
'testsCount',
],
+ data() {
+ return {
+ activeTab: this.defaultTabValue,
+ };
+ },
computed: {
showFailedJobsTab() {
return this.failedJobsCount > 0;
},
},
+ watch: {
+ $route(to) {
+ this.activeTab = to.name;
+ },
+ },
methods: {
isActive(tabName) {
- return tabName === this.defaultTabValue;
+ return tabName === this.activeTab;
+ },
+ navigateTo(tabName) {
+ this.$router.push({ name: tabName });
},
},
};
@@ -59,10 +69,12 @@ export default {
<gl-tab
ref="pipelineTab"
:title="$options.i18n.tabs.pipelineTitle"
+ :active="isActive($options.tabNames.pipeline)"
data-testid="pipeline-tab"
lazy
+ @click="navigateTo($options.tabNames.pipeline)"
>
- <pipeline-graph-wrapper />
+ <router-view />
</gl-tab>
<gl-tab
ref="dagTab"
@@ -70,15 +82,21 @@ export default {
:active="isActive($options.tabNames.needs)"
data-testid="dag-tab"
lazy
+ @click="navigateTo($options.tabNames.needs)"
>
- <dag />
+ <router-view />
</gl-tab>
- <gl-tab :active="isActive($options.tabNames.jobs)" data-testid="jobs-tab" lazy>
+ <gl-tab
+ :active="isActive($options.tabNames.jobs)"
+ data-testid="jobs-tab"
+ lazy
+ @click="navigateTo($options.tabNames.jobs)"
+ >
<template #title>
<span class="gl-mr-2">{{ $options.i18n.tabs.jobsTitle }}</span>
<gl-badge size="sm" data-testid="builds-counter">{{ totalJobCount }}</gl-badge>
</template>
- <jobs-app />
+ <router-view />
</gl-tab>
<gl-tab
v-if="showFailedJobsTab"
@@ -86,19 +104,25 @@ export default {
:active="isActive($options.tabNames.failures)"
data-testid="failed-jobs-tab"
lazy
+ @click="navigateTo($options.tabNames.failures)"
>
<template #title>
<span class="gl-mr-2">{{ $options.i18n.tabs.failedJobsTitle }}</span>
<gl-badge size="sm" data-testid="failed-builds-counter">{{ failedJobsCount }}</gl-badge>
</template>
- <failed-jobs-app :failed-jobs-summary="failedJobsSummary" />
+ <router-view :failed-jobs-summary="failedJobsSummary" />
</gl-tab>
- <gl-tab :active="isActive($options.tabNames.tests)" data-testid="tests-tab" lazy>
+ <gl-tab
+ :active="isActive($options.tabNames.tests)"
+ data-testid="tests-tab"
+ lazy
+ @click="navigateTo($options.tabNames.tests)"
+ >
<template #title>
<span class="gl-mr-2">{{ $options.i18n.tabs.testsTitle }}</span>
<gl-badge size="sm" data-testid="tests-counter">{{ testsCount }}</gl-badge>
</template>
- <test-reports />
+ <router-view />
</gl-tab>
<slot></slot>
</gl-tabs>
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 39d41415456..fe2ef2c2d71 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -115,6 +115,9 @@ export default {
commitTitle() {
return this.pipeline?.commit?.title;
},
+ pipelineName() {
+ return this.pipeline?.name;
+ },
},
methods: {
trackClick(action) {
@@ -125,7 +128,18 @@ export default {
</script>
<template>
<div class="pipeline-tags" data-testid="pipeline-url-table-cell">
- <div class="commit-title gl-mb-2" data-testid="commit-title-container">
+ <div v-if="pipelineName" class="gl-mb-2" data-testid="pipeline-name-container">
+ <span class="gl-display-flex">
+ <tooltip-on-truncate
+ :title="pipelineName"
+ class="gl-flex-grow-1 gl-text-truncate gl-text-gray-900"
+ >
+ {{ pipelineName }}
+ </tooltip-on-truncate>
+ </span>
+ </div>
+
+ <div v-if="!pipelineName" class="commit-title gl-mb-2" data-testid="commit-title-container">
<span v-if="commitTitle" class="gl-display-flex">
<tooltip-on-truncate :title="commitTitle" class="gl-flex-grow-1 gl-text-truncate">
<gl-link
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 387438fb726..cd44c998074 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -1,12 +1,14 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { formatDate, getTimeago, durationTimeFormatted } from '~/lib/utils/datetime_utility';
+import { durationTimeFormatted } from '~/lib/utils/datetime_utility';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: { GlIcon },
+ mixins: [timeagoMixin],
props: {
pipeline: {
type: Object,
@@ -35,12 +37,6 @@ export default {
showSkipped() {
return !this.duration && !this.finishedTime && this.skipped;
},
- timeFormatted() {
- return getTimeago().format(this.finishedTime);
- },
- tooltipTitle() {
- return formatDate(this.finishedTime);
- },
},
};
</script>
@@ -73,12 +69,12 @@ export default {
<time
v-gl-tooltip
- :title="tooltipTitle"
+ :title="tooltipTitle(finishedTime)"
:datetime="finishedTime"
data-placement="top"
data-container="body"
>
- {{ timeFormatted }}
+ {{ timeFormatted(finishedTime) }}
</time>
</p>
</div>
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 69509c9088b..2d1f1945e5a 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
@@ -73,6 +73,7 @@ export default {
<template>
<gl-modal
+ data-testid="test-case-details-modal"
:modal-id="modalId"
:title="testCase.classname"
:action-primary="$options.modalCloseButton"
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 7d0f1ba4b5f..1cd28e027f3 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
@@ -112,21 +112,21 @@ export default {
>
<div class="table-section section-20 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ __('Suite') }}</div>
- <div class="table-mobile-content gl-md-pr-2 gl-overflow-wrap-break">
+ <div class="table-mobile-content gl-pr-0 gl-sm-pr-2 gl-overflow-wrap-break">
<gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.classname" />
</div>
</div>
<div class="table-section section-40 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div>
- <div class="table-mobile-content gl-md-pr-2 gl-overflow-wrap-break">
+ <div class="table-mobile-content gl-pr-0 gl-sm-pr-2 gl-overflow-wrap-break">
<gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.name" />
</div>
</div>
<div class="table-section section-10 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ __('Filename') }}</div>
- <div class="table-mobile-content gl-md-pr-2 gl-overflow-wrap-break">
+ <div class="table-mobile-content gl-pr-0 gl-sm-pr-2 gl-overflow-wrap-break">
<gl-link v-if="testCase.file" :href="testCase.filePath" target="_blank">
<gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.file" />
</gl-link>
@@ -156,7 +156,7 @@ export default {
<div role="rowheader" class="table-mobile-header">
{{ __('Duration') }}
</div>
- <div class="table-mobile-content gl-sm-pr-2">
+ <div class="table-mobile-content gl-pr-0 gl-sm-pr-2">
{{ testCase.formattedTime }}
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 327633dcb1a..ed8ec614304 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -47,8 +47,7 @@ export const CHILD_VIEW = 'child';
// Pipeline tabs
-export const TAB_QUERY_PARAM = 'tab';
-
+export const pipelineTabName = 'graph';
export const needsTabName = 'dag';
export const jobsTabName = 'builds';
export const failedJobsTabName = 'failures';
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 3744649e9d5..1bbdd3625be 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -1,3 +1,4 @@
+import VueRouter from 'vue-router';
import { createAlert } from '~/flash';
import { __, s__ } from '~/locale';
import createDagApp from './pipeline_details_dag';
@@ -32,9 +33,16 @@ export default async function initPipelineDetailsBundle() {
if (gon.features?.pipelineTabsVue) {
const { createAppOptions } = await import('ee_else_ce/pipelines/pipeline_tabs');
const { createPipelineTabs } = await import('./pipeline_tabs');
+ const { routes } = await import('ee_else_ce/pipelines/routes');
+
+ const router = new VueRouter({
+ mode: 'history',
+ base: dataset.pipelinePath,
+ routes,
+ });
try {
- const appOptions = createAppOptions(SELECTORS.PIPELINE_TABS, apolloProvider);
+ const appOptions = createAppOptions(SELECTORS.PIPELINE_TABS, apolloProvider, router);
createPipelineTabs(appOptions);
} catch {
createAlert({
diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js
index 508f188c229..d0ee6871a48 100644
--- a/app/assets/javascripts/pipelines/pipeline_tabs.js
+++ b/app/assets/javascripts/pipelines/pipeline_tabs.js
@@ -1,17 +1,17 @@
import Vue from 'vue';
+import VueRouter from 'vue-router';
import Vuex from 'vuex';
import VueApollo from 'vue-apollo';
import PipelineTabs from 'ee_else_ce/pipelines/components/pipeline_tabs.vue';
-import { removeParams, updateHistory } from '~/lib/utils/url_utility';
-import { TAB_QUERY_PARAM } from '~/pipelines/constants';
import { parseBoolean } from '~/lib/utils/common_utils';
import createTestReportsStore from './stores/test_reports';
import { getPipelineDefaultTab, reportToSentry } from './utils';
Vue.use(VueApollo);
+Vue.use(VueRouter);
Vue.use(Vuex);
-export const createAppOptions = (selector, apolloProvider) => {
+export const createAppOptions = (selector, apolloProvider, router) => {
const el = document.querySelector(selector);
if (!el) return null;
@@ -40,6 +40,7 @@ export const createAppOptions = (selector, apolloProvider) => {
suiteEndpoint,
blobPath,
hasTestReport,
+ emptyDagSvgPath,
emptyStateImagePath,
artifactsExpiredImagePath,
isFullCodequalityReportAvailable,
@@ -65,6 +66,7 @@ export const createAppOptions = (selector, apolloProvider) => {
}),
},
}),
+ router,
provide: {
canGenerateCodequalityReports: parseBoolean(canGenerateCodequalityReports),
codequalityReportDownloadPath,
@@ -91,6 +93,7 @@ export const createAppOptions = (selector, apolloProvider) => {
suiteEndpoint,
blobPath,
hasTestReport,
+ emptyDagSvgPath,
emptyStateImagePath,
artifactsExpiredImagePath,
testsCount,
@@ -107,12 +110,6 @@ export const createAppOptions = (selector, apolloProvider) => {
export const createPipelineTabs = (options) => {
if (!options) return;
- updateHistory({
- url: removeParams([TAB_QUERY_PARAM]),
- title: document.title,
- replace: true,
- });
-
// eslint-disable-next-line no-new
new Vue(options);
};
diff --git a/app/assets/javascripts/pipelines/routes.js b/app/assets/javascripts/pipelines/routes.js
new file mode 100644
index 00000000000..0e1414ec390
--- /dev/null
+++ b/app/assets/javascripts/pipelines/routes.js
@@ -0,0 +1,20 @@
+import PipelineGraphWrapper from './components/graph/graph_component_wrapper.vue';
+import Dag from './components/dag/dag.vue';
+import FailedJobsApp from './components/jobs/failed_jobs_app.vue';
+import JobsApp from './components/jobs/jobs_app.vue';
+import TestReports from './components/test_reports/test_reports.vue';
+import {
+ pipelineTabName,
+ needsTabName,
+ jobsTabName,
+ failedJobsTabName,
+ testReportTabName,
+} from './constants';
+
+export const routes = [
+ { name: pipelineTabName, path: '/', component: PipelineGraphWrapper },
+ { name: needsTabName, path: '/dag', component: Dag },
+ { name: jobsTabName, path: '/builds', component: JobsApp },
+ { name: failedJobsTabName, path: '/failures', component: FailedJobsApp },
+ { name: testReportTabName, path: '/test_report', component: TestReports },
+];
diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js
index 588d15495ab..b8276327843 100644
--- a/app/assets/javascripts/pipelines/utils.js
+++ b/app/assets/javascripts/pipelines/utils.js
@@ -1,12 +1,7 @@
import * as Sentry from '@sentry/browser';
import { pickBy } from 'lodash';
-import { getParameterValues } from '~/lib/utils/url_utility';
-import {
- NEEDS_PROPERTY,
- SUPPORTED_FILTER_PARAMETERS,
- TAB_QUERY_PARAM,
- validPipelineTabNames,
-} from './constants';
+import { parseUrlPathname } from '~/lib/utils/url_utility';
+import { NEEDS_PROPERTY, SUPPORTED_FILTER_PARAMETERS, validPipelineTabNames } from './constants';
/*
The following functions are the main engine in transforming the data as
received from the endpoint into the format the d3 graph expects.
@@ -145,10 +140,12 @@ export const reportMessageToSentry = (component, message, context) => {
};
export const getPipelineDefaultTab = (url) => {
- const [tabQueryValue] = getParameterValues(TAB_QUERY_PARAM, url);
+ const strippedUrl = parseUrlPathname(url);
+ const regexp = /\w*$/;
+ const [tabName] = strippedUrl.match(regexp);
- if (tabQueryValue && validPipelineTabNames.includes(tabQueryValue)) {
- return tabQueryValue;
+ if (tabName && validPipelineTabNames.includes(tabName)) {
+ return tabName;
}
return null;
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 2802e4a90b9..0256eec6d56 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
@@ -124,7 +124,7 @@ export default {
</script>
<template>
- <div class="gl-pt-2">
+ <div>
<gl-loading-icon v-if="$apollo.queries.pipeline.loading" />
<pipeline-mini-graph
v-else
diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js
index 603fdfdf80a..9365066418b 100644
--- a/app/assets/javascripts/projects/commits/store/actions.js
+++ b/app/assets/javascripts/projects/commits/store/actions.js
@@ -3,6 +3,7 @@ import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
+import { ACTIVE_AND_BLOCKED_USER_STATES } from '~/users_select/constants';
import * as types from './mutation_types';
export default {
@@ -23,7 +24,7 @@ export default {
.get(joinPaths(gon.relative_url_root || '', '/-/autocomplete/users.json'), {
params: {
project_id: projectId,
- active: true,
+ states: ACTIVE_AND_BLOCKED_USER_STATES,
search: author,
},
})
diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue
index 271694863e8..e4d5e5bd233 100644
--- a/app/assets/javascripts/projects/compare/components/app.vue
+++ b/app/assets/javascripts/projects/compare/components/app.vue
@@ -131,7 +131,7 @@ export default {
<revision-card
data-testid="sourceRevisionCard"
:refs-project-path="to.refsProjectPath"
- revision-text="Source"
+ :revision-text="__('Source')"
params-name="to"
:params-branch="to.revision"
:projects="to.projects"
@@ -160,7 +160,7 @@ export default {
<revision-card
data-testid="targetRevisionCard"
:refs-project-path="from.refsProjectPath"
- revision-text="Target"
+ :revision-text="__('Target')"
params-name="from"
:params-branch="from.revision"
:projects="from.projects"
diff --git a/app/assets/javascripts/projects/components/shared/delete_button.vue b/app/assets/javascripts/projects/components/shared/delete_button.vue
index 277af2f281e..64a16b462f5 100644
--- a/app/assets/javascripts/projects/components/shared/delete_button.vue
+++ b/app/assets/javascripts/projects/components/shared/delete_button.vue
@@ -62,7 +62,11 @@ export default {
return {
primary: {
text: __('Yes, delete project'),
- attributes: [{ variant: 'danger' }, { disabled: this.confirmDisabled }],
+ attributes: [
+ { variant: 'danger' },
+ { disabled: this.confirmDisabled },
+ { 'data-qa-selector': 'confirm_delete_button' },
+ ],
},
cancel: {
text: __('Cancel, keep project'),
@@ -97,9 +101,13 @@ export default {
<input type="hidden" name="_method" value="delete" />
<input :value="csrfToken" type="hidden" name="authenticity_token" />
- <gl-button v-gl-modal="modalId" category="primary" variant="danger">{{
- $options.strings.deleteProject
- }}</gl-button>
+ <gl-button
+ v-gl-modal="modalId"
+ category="primary"
+ variant="danger"
+ data-qa-selector="delete_button"
+ >{{ $options.strings.deleteProject }}</gl-button
+ >
<gl-modal
ref="removeModal"
@@ -168,6 +176,7 @@ export default {
v-model="userInput"
name="confirm_name_input"
type="text"
+ data-qa-selector="confirm_name_field"
/>
<slot name="modal-footer"></slot>
</div>
diff --git a/app/assets/javascripts/projects/new/components/new_project_push_tip_popover.vue b/app/assets/javascripts/projects/new/components/new_project_push_tip_popover.vue
index e42d9154866..c23644b40d2 100644
--- a/app/assets/javascripts/projects/new/components/new_project_push_tip_popover.vue
+++ b/app/assets/javascripts/projects/new/components/new_project_push_tip_popover.vue
@@ -56,7 +56,7 @@ export default {
</p>
<p>
<a
- :href="`${workingWithProjectsHelpPath}#push-to-create-a-new-project`"
+ :href="`${workingWithProjectsHelpPath}#create-a-new-project-with-git-push`"
class="gl-font-sm"
target="_blank"
>{{ $options.i18n.helpLinkText }}</a
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
index 186fcf70838..4643bfe58f6 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
@@ -2,6 +2,8 @@
import { GlTabs, GlTab } from '@gitlab/ui';
import API from '~/api';
import { mergeUrlParams, updateHistory, getParameterValues } from '~/lib/utils/url_utility';
+import Tracking from '~/tracking';
+import { SNOWPLOW_DATA_SOURCE, SNOWPLOW_LABEL, SNOWPLOW_SCHEMA } from '../constants';
import PipelineCharts from './pipeline_charts.vue';
export default {
@@ -23,6 +25,7 @@ export default {
leadTimeTabEvent: 'p_analytics_ci_cd_lead_time',
timeToRestoreServiceTabEvent: 'p_analytics_ci_cd_time_to_restore_service',
changeFailureRateTabEvent: 'p_analytics_ci_cd_change_failure_rate',
+ mixins: [Tracking.mixin()],
inject: {
shouldRenderDoraCharts: {
type: Boolean,
@@ -75,8 +78,21 @@ export default {
updateHistory({ url: path, title: window.title });
}
},
- trackTabClick(tab) {
- API.trackRedisHllUserEvent(tab);
+ trackTabClick(event, snowplow = false) {
+ API.trackRedisHllUserEvent(event);
+
+ if (snowplow) {
+ this.track('click_tab', {
+ label: SNOWPLOW_LABEL,
+ context: {
+ schema: SNOWPLOW_SCHEMA,
+ data: {
+ event_name: event,
+ data_source: SNOWPLOW_DATA_SOURCE,
+ },
+ },
+ });
+ }
},
},
};
@@ -87,7 +103,7 @@ export default {
<gl-tab
:title="__('Pipelines')"
data-testid="pipelines-tab"
- @click="trackTabClick($options.piplelinesTabEvent)"
+ @click="trackTabClick($options.piplelinesTabEvent, true)"
>
<pipeline-charts />
</gl-tab>
@@ -95,14 +111,14 @@ export default {
<gl-tab
:title="__('Deployment frequency')"
data-testid="deployment-frequency-tab"
- @click="trackTabClick($options.deploymentFrequencyTabEvent)"
+ @click="trackTabClick($options.deploymentFrequencyTabEvent, true)"
>
<deployment-frequency-charts />
</gl-tab>
<gl-tab
:title="__('Lead time')"
data-testid="lead-time-tab"
- @click="trackTabClick($options.leadTimeTabEvent)"
+ @click="trackTabClick($options.leadTimeTabEvent, true)"
>
<lead-time-charts />
</gl-tab>
diff --git a/app/assets/javascripts/projects/pipelines/charts/constants.js b/app/assets/javascripts/projects/pipelines/charts/constants.js
index 41fe81f21ea..c13824a9952 100644
--- a/app/assets/javascripts/projects/pipelines/charts/constants.js
+++ b/app/assets/javascripts/projects/pipelines/charts/constants.js
@@ -19,3 +19,7 @@ export const PARSE_FAILURE = 'parse_failure';
export const LOAD_ANALYTICS_FAILURE = 'load_analytics_failure';
export const LOAD_PIPELINES_FAILURE = 'load_analytics_failure';
export const UNSUPPORTED_DATA = 'unsupported_data';
+
+export const SNOWPLOW_LABEL = 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly';
+export const SNOWPLOW_SCHEMA = 'iglu:com.gitlab/gitlab_service_ping/jsonschema/1-0-0';
+export const SNOWPLOW_DATA_SOURCE = 'redis_hll';
diff --git a/app/assets/javascripts/projects/settings/api/access_dropdown_api.js b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js
index 10f6c28a7bf..df99aac6b9e 100644
--- a/app/assets/javascripts/projects/settings/api/access_dropdown_api.js
+++ b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js
@@ -12,7 +12,7 @@ const buildUrl = (urlRoot, url) => {
return newUrl;
};
-export const getUsers = (query) => {
+export const getUsers = (query, states) => {
return axios.get(buildUrl(gon.relative_url_root || '', USERS_PATH), {
params: {
search: query,
@@ -20,6 +20,7 @@ export const getUsers = (query) => {
active: true,
project_id: gon.current_project_id,
push_code: true,
+ states,
},
});
};
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 264c2629433..6da058ebc9c 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
@@ -21,9 +21,14 @@ export const I18N = {
approvalsTitle: s__('BranchRules|Approvals'),
manageApprovalsLinkTitle: s__('BranchRules|Manage in Merge Request Approvals'),
approvalsDescription: s__(
- 'BranchRules|Approvals to ensure separation of duties for new merge requests. %{linkStart}Lean more.%{linkEnd}',
+ 'BranchRules|Approvals to ensure separation of duties for new merge requests. %{linkStart}Learn more.%{linkEnd}',
),
statusChecksTitle: s__('BranchRules|Status checks'),
+ statusChecksDescription: s__(
+ 'BranchRules|Check for a status response in merge requests. Failures do not block merges. %{linkStart}Learn more.%{linkEnd}',
+ ),
+ statusChecksLinkTitle: s__('BranchRules|Manage in Status checks'),
+ statusChecksHeader: s__('BranchRules|Status checks (%{total})'),
allowedToPushHeader: s__('BranchRules|Allowed to push (%{total})'),
allowedToMergeHeader: s__('BranchRules|Allowed to merge (%{total})'),
approvalsHeader: s__('BranchRules|Required approvals (%{total})'),
@@ -40,3 +45,5 @@ export const WILDCARDS_HELP_PATH =
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';
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 318940478a8..eb11e17dd1b 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
@@ -12,11 +12,13 @@ import {
WILDCARDS_HELP_PATH,
PROTECTED_BRANCHES_HELP_PATH,
APPROVALS_HELP_PATH,
+ STATUS_CHECKS_HELP_PATH,
} from './constants';
const wildcardsHelpDocLink = helpPagePath(WILDCARDS_HELP_PATH);
const protectedBranchesHelpDocLink = helpPagePath(PROTECTED_BRANCHES_HELP_PATH);
const approvalsHelpDocLink = helpPagePath(APPROVALS_HELP_PATH);
+const statusChecksHelpDocLink = helpPagePath(STATUS_CHECKS_HELP_PATH);
export default {
name: 'RuleView',
@@ -24,6 +26,7 @@ export default {
wildcardsHelpDocLink,
protectedBranchesHelpDocLink,
approvalsHelpDocLink,
+ statusChecksHelpDocLink,
components: { Protection, GlSprintf, GlLink, GlLoadingIcon },
inject: {
projectPath: {
@@ -35,6 +38,9 @@ export default {
approvalRulesPath: {
default: '',
},
+ statusChecksPath: {
+ default: '',
+ },
},
apollo: {
project: {
@@ -45,18 +51,19 @@ export default {
};
},
update({ project: { branchRules } }) {
- this.branchProtection = branchRules.nodes.find(
- (rule) => rule.name === this.branch,
- )?.branchProtection;
+ const branchRule = branchRules.nodes.find((rule) => rule.name === this.branch);
+ this.branchProtection = branchRule?.branchProtection;
+ this.approvalRules = branchRule?.approvalRules;
+ this.statusChecks = branchRule?.externalStatusChecks?.nodes || [];
},
},
},
data() {
return {
branch: getParameterByName(BRANCH_PARAM_NAME),
- branchProtection: {
- approvalRules: {},
- },
+ branchProtection: {},
+ approvalRules: {},
+ statusChecks: [],
};
},
computed: {
@@ -92,6 +99,11 @@ export default {
total,
});
},
+ statusChecksHeader() {
+ return sprintf(this.$options.i18n.statusChecksHeader, {
+ total: this.statusChecks.length,
+ });
+ },
allBranches() {
return this.branch === ALL_BRANCHES_WILDCARD;
},
@@ -104,7 +116,7 @@ export default {
: this.$options.i18n.branchNameOrPattern;
},
approvals() {
- return this.branchProtection?.approvalRules?.nodes || [];
+ return this.approvalRules?.nodes || [];
},
},
methods: {
@@ -202,6 +214,21 @@ export default {
/>
<!-- Status checks -->
- <!-- Follow-up: add status checks section (https://gitlab.com/gitlab-org/gitlab/-/issues/372362) -->
+ <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.statusChecksTitle }}</h4>
+ <gl-sprintf :message="$options.i18n.statusChecksDescription">
+ <template #link="{ content }">
+ <gl-link :href="$options.statusChecksHelpDocLink">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+
+ <protection
+ class="gl-mt-3"
+ :header="statusChecksHeader"
+ :header-link-title="$options.i18n.statusChecksLinkTitle"
+ :header-link-href="statusChecksPath"
+ :status-checks="statusChecks"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection.vue
index cfe2df0dbda..813c667dcdd 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection.vue
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection.vue
@@ -46,6 +46,11 @@ export default {
required: false,
default: () => [],
},
+ statusChecks: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
computed: {
showUsersDivider() {
@@ -95,5 +100,14 @@ export default {
:users="approval.eligibleApprovers.nodes"
:approvals-required="approval.approvalsRequired"
/>
+
+ <!-- Status checks -->
+ <protection-row
+ v-for="(statusCheck, index) in statusChecks"
+ :key="statusCheck.id"
+ :show-divider="index !== 0"
+ :title="statusCheck.name"
+ :status-check-url="statusCheck.externalUrl"
+ />
</gl-card>
</template>
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 28a1c09fa82..9bff2f5506c 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
@@ -41,6 +41,11 @@ export default {
required: false,
default: 0,
},
+ statusCheckUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
avatarBadgeSrOnlyText() {
@@ -64,10 +69,10 @@ export default {
<template>
<div
- class="gl-display-flex gl-align-items-center gl-border-gray-100 gl-mb-4 gl-pt-4"
+ class="gl-display-flex gl-align-items-center gl-border-gray-100 gl-mb-4 gl-pt-4 gl-border-t-1"
:class="{ 'gl-border-t-solid': showDivider }"
>
- <div class="gl-display-flex gl-w-half gl-justify-content-space-between">
+ <div class="gl-display-flex gl-w-full gl-justify-content-space-between gl-align-items-center">
<div class="gl-mr-7 gl-w-quarter">{{ title }}</div>
<gl-avatars-inline
@@ -94,6 +99,8 @@ export default {
</template>
</gl-avatars-inline>
+ <div v-if="statusCheckUrl" class="gl-ml-7 gl-flex-grow-1">{{ statusCheckUrl }}</div>
+
<div
v-for="(item, index) in accessLevels"
:key="index"
@@ -104,7 +111,7 @@ export default {
{{ item.accessLevelDescription }}
</div>
- <div class="gl-ml-7 gl-w-quarter">{{ approvalsRequiredTitle }}</div>
+ <div class="gl-ml-7 gl-flex-grow-1">{{ approvalsRequiredTitle }}</div>
</div>
</div>
</template>
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 07fd0a7080f..89cfb1e1c8e 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
@@ -14,7 +14,7 @@ export default function mountBranchRules(el) {
defaultClient: createDefaultClient(),
});
- const { projectPath, protectedBranchesPath, approvalRulesPath } = el.dataset;
+ const { projectPath, protectedBranchesPath, approvalRulesPath, statusChecksPath } = el.dataset;
return new Vue({
el,
@@ -23,6 +23,7 @@ export default function mountBranchRules(el) {
projectPath,
protectedBranchesPath,
approvalRulesPath,
+ statusChecksPath,
},
render(h) {
return h(View);
diff --git a/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql b/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql
index 3ac165498a1..aa1e4923aa8 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql
+++ b/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql
@@ -44,6 +44,30 @@ query getBranchRulesDetails($projectPath: ID!) {
}
}
}
+ approvalRules {
+ nodes {
+ id
+ name
+ type
+ approvalsRequired
+ eligibleApprovers {
+ nodes {
+ id
+ name
+ username
+ webUrl
+ avatarUrl
+ }
+ }
+ }
+ }
+ externalStatusChecks {
+ nodes {
+ id
+ name
+ externalUrl
+ }
+ }
}
}
}
diff --git a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue
index 55420c9c732..fd5fabd7c8a 100644
--- a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue
+++ b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue
@@ -1,30 +1,14 @@
<script>
-import { GlFormGroup, GlAlert } from '@gitlab/ui';
-import { debounce } from 'lodash';
import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue';
-import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select_deprecated.vue';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import TransferLocations from '~/groups_projects/components/transfer_locations.vue';
import { getTransferLocations } from '~/api/projects_api';
-import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
-import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
-import { s__, __ } from '~/locale';
-import currentUserNamespace from '../graphql/queries/current_user_namespace.query.graphql';
export default {
name: 'TransferProjectForm',
components: {
- GlFormGroup,
- NamespaceSelect,
+ TransferLocations,
ConfirmDanger,
- GlAlert,
},
- i18n: {
- errorMessage: s__(
- 'ProjectTransfer|An error occurred fetching the transfer locations, please refresh the page and try again.',
- ),
- alertDismissAlert: __('Dismiss'),
- },
- inject: ['projectId'],
props: {
confirmationPhrase: {
type: String,
@@ -37,150 +21,37 @@ export default {
},
data() {
return {
- userNamespaces: [],
- groupNamespaces: [],
- initialNamespacesLoaded: false,
- selectedNamespace: null,
- hasError: false,
- isLoading: false,
- isSearchLoading: false,
- searchTerm: '',
- page: 1,
- totalPages: 1,
+ selectedTransferLocation: null,
};
},
+
computed: {
hasSelectedNamespace() {
- return Boolean(this.selectedNamespace?.id);
+ return Boolean(this.selectedTransferLocation?.id);
},
- hasNextPageOfGroups() {
- return this.page < this.totalPages;
+ },
+ watch: {
+ selectedTransferLocation(selectedTransferLocation) {
+ this.$emit('selectTransferLocation', selectedTransferLocation.id);
},
},
methods: {
- async handleShow() {
- if (this.initialNamespacesLoaded) {
- return;
- }
-
- this.isLoading = true;
-
- [this.groupNamespaces, this.userNamespaces] = await Promise.all([
- this.getGroupNamespaces(),
- this.getUserNamespaces(),
- ]);
-
- this.isLoading = false;
- this.initialNamespacesLoaded = true;
- },
- handleSelect(selectedNamespace) {
- this.selectedNamespace = selectedNamespace;
- this.$emit('selectNamespace', selectedNamespace.id);
- },
- async getGroupNamespaces() {
- try {
- const { data: groupNamespaces, headers } = await getTransferLocations(this.projectId, {
- page: this.page,
- search: this.searchTerm,
- });
-
- const { totalPages } = parseIntPagination(normalizeHeaders(headers));
- this.totalPages = totalPages;
-
- return groupNamespaces.map(({ id, full_name: humanName }) => ({
- id,
- humanName,
- }));
- } catch (error) {
- this.hasError = true;
-
- return [];
- }
- },
- async getUserNamespaces() {
- try {
- const {
- data: {
- currentUser: { namespace },
- },
- } = await this.$apollo.query({
- query: currentUserNamespace,
- });
-
- if (!namespace) {
- return [];
- }
-
- return [
- {
- id: getIdFromGraphQLId(namespace.id),
- humanName: namespace.fullName,
- },
- ];
- } catch (error) {
- this.hasError = true;
-
- return [];
- }
- },
- async handleLoadMoreGroups() {
- this.isLoading = true;
- this.page += 1;
-
- const groupNamespaces = await this.getGroupNamespaces();
- this.groupNamespaces.push(...groupNamespaces);
-
- this.isLoading = false;
- },
- debouncedSearch: debounce(async function debouncedSearch() {
- this.isSearchLoading = true;
-
- this.groupNamespaces = await this.getGroupNamespaces();
-
- this.isSearchLoading = false;
- }, DEBOUNCE_DELAY),
- handleSearch(searchTerm) {
- this.searchTerm = searchTerm;
- this.page = 1;
-
- this.debouncedSearch();
- },
- handleAlertDismiss() {
- this.hasError = false;
- },
+ getTransferLocations,
},
};
</script>
<template>
<div>
- <gl-alert
- v-if="hasError"
- variant="danger"
- :dismiss-label="$options.i18n.alertDismissLabel"
- @dismiss="handleAlertDismiss"
- >{{ $options.i18n.errorMessage }}</gl-alert
- >
- <gl-form-group>
- <namespace-select
- data-testid="transfer-project-namespace"
- :full-width="true"
- :group-namespaces="groupNamespaces"
- :user-namespaces="userNamespaces"
- :selected-namespace="selectedNamespace"
- :has-next-page-of-groups="hasNextPageOfGroups"
- :is-loading="isLoading"
- :is-search-loading="isSearchLoading"
- :should-filter-namespaces="false"
- @select="handleSelect"
- @load-more-groups="handleLoadMoreGroups"
- @search="handleSearch"
- @show="handleShow"
- />
- </gl-form-group>
+ <transfer-locations
+ v-model="selectedTransferLocation"
+ data-testid="transfer-project-namespace"
+ :group-transfer-locations-api-method="getTransferLocations"
+ />
<confirm-danger
:disabled="!hasSelectedNamespace"
:phrase="confirmationPhrase"
:button-text="confirmButtonText"
+ button-qa-selector="transfer_project_button"
@confirm="$emit('confirm')"
/>
</div>
diff --git a/app/assets/javascripts/projects/settings/init_transfer_project_form.js b/app/assets/javascripts/projects/settings/init_transfer_project_form.js
index 89c158a9ba8..7f810e647ae 100644
--- a/app/assets/javascripts/projects/settings/init_transfer_project_form.js
+++ b/app/assets/javascripts/projects/settings/init_transfer_project_form.js
@@ -12,7 +12,7 @@ export default () => {
Vue.use(VueApollo);
const {
- projectId,
+ projectId: resourceId,
targetFormId = null,
targetHiddenInputId = null,
buttonText: confirmButtonText = '',
@@ -27,7 +27,7 @@ export default () => {
}),
provide: {
confirmDangerMessage,
- projectId,
+ resourceId,
},
render(createElement) {
return createElement(TransferProjectForm, {
@@ -36,7 +36,7 @@ export default () => {
confirmationPhrase,
},
on: {
- selectNamespace: (id) => {
+ selectTransferLocation: (id) => {
if (targetHiddenInputId && document.getElementById(targetHiddenInputId)) {
document.getElementById(targetHiddenInputId).value = id;
}
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 94793a535cc..a9eb2a53fbf 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
@@ -50,7 +50,15 @@ export default {
<template>
<div class="settings-content">
- <branch-rule v-for="rule in branchRules" :key="rule.name" :name="rule.name" />
+ <branch-rule
+ v-for="rule in branchRules"
+ :key="rule.name"
+ :name="rule.name"
+ :is-default="rule.isDefault"
+ :branch-protection="rule.branchProtection"
+ :status-checks-total="rule.externalStatusChecks.nodes.length"
+ :approval-rules-total="rule.approvalRules.nodes.length"
+ />
<span v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</span>
</div>
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 2b88f8561d7..78c824c66d1 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
@@ -1,11 +1,14 @@
<script>
import { GlBadge, GlButton } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { s__, sprintf, n__ } from '~/locale';
export const i18n = {
defaultLabel: s__('BranchRules|default'),
- protectedLabel: s__('BranchRules|protected'),
detailsButtonLabel: s__('BranchRules|Details'),
+ allowForcePush: s__('BranchRules|Allowed to force push'),
+ codeOwnerApprovalRequired: s__('BranchRules|Requires CODEOWNERS approval'),
+ statusChecks: s__('BranchRules|%{total} status %{subject}'),
+ approvalRules: s__('BranchRules|%{total} approval %{subject}'),
};
export default {
@@ -30,24 +33,57 @@ export default {
required: false,
default: false,
},
- isProtected: {
- type: Boolean,
+ branchProtection: {
+ type: Object,
required: false,
- default: false,
+ default: () => {},
},
- approvalDetails: {
- type: Array,
+ statusChecksTotal: {
+ type: Number,
required: false,
- default: () => [],
+ default: 0,
+ },
+ approvalRulesTotal: {
+ type: Number,
+ required: false,
+ default: 0,
},
},
computed: {
hasApprovalDetails() {
- return this.approvalDetails && this.approvalDetails.length;
+ return this.approvalDetails.length;
},
detailsPath() {
return `${this.branchRulesPath}?branch=${this.name}`;
},
+ statusChecksText() {
+ return sprintf(this.$options.i18n.statusChecks, {
+ total: this.statusChecksTotal,
+ subject: n__('check', 'checks', this.statusChecksTotal),
+ });
+ },
+ approvalRulesText() {
+ return sprintf(this.$options.i18n.approvalRules, {
+ total: this.approvalRulesTotal,
+ subject: n__('rule', 'rules', this.approvalRulesTotal),
+ });
+ },
+ approvalDetails() {
+ const approvalDetails = [];
+ if (this.branchProtection.allowForcePush) {
+ approvalDetails.push(this.$options.i18n.allowForcePush);
+ }
+ if (this.branchProtection.codeOwnerApprovalRequired) {
+ approvalDetails.push(this.$options.i18n.codeOwnerApprovalRequired);
+ }
+ if (this.statusChecksTotal) {
+ approvalDetails.push(this.statusChecksText);
+ }
+ if (this.approvalRulesTotal) {
+ approvalDetails.push(this.approvalRulesText);
+ }
+ return approvalDetails;
+ },
},
};
</script>
@@ -61,14 +97,12 @@ export default {
$options.i18n.defaultLabel
}}</gl-badge>
- <gl-badge v-if="isProtected" variant="success" size="sm" class="gl-ml-2">{{
- $options.i18n.protectedLabel
- }}</gl-badge>
-
<ul v-if="hasApprovalDetails" class="gl-pl-6 gl-mt-2 gl-mb-0 gl-text-gray-500">
<li v-for="(detail, index) in approvalDetails" :key="index">{{ detail }}</li>
</ul>
</div>
- <gl-button :href="detailsPath"> {{ $options.i18n.detailsButtonLabel }}</gl-button>
+ <gl-button class="gl-align-self-start" :href="detailsPath">
+ {{ $options.i18n.detailsButtonLabel }}</gl-button
+ >
</div>
</template>
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql b/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql
index 104a0c25a80..49e089e7805 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql
@@ -4,6 +4,21 @@ query getBranchRules($projectPath: ID!) {
branchRules {
nodes {
name
+ isDefault
+ branchProtection {
+ allowForcePush
+ codeOwnerApprovalRequired
+ }
+ externalStatusChecks {
+ nodes {
+ id
+ }
+ }
+ approvalRules {
+ nodes {
+ id
+ }
+ }
}
}
}
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 452e7a4fd21..85550e262e6 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
@@ -265,35 +265,14 @@ export default {
class="mt-3"
>
<gl-form-input
- v-if="hasProjectKeySupport"
id="service-desk-email-from-name"
v-model.trim="outgoingName"
data-testid="email-from-name"
/>
- <template v-if="hasProjectKeySupport" #description>
+ <template #description>
{{ __('Name to be used as the sender for emails from Service Desk.') }}
</template>
- <template v-else #description>
- <span class="gl-text-gray-900">
- <gl-sprintf
- :message="
- __(
- 'To add display name, set up a Service Desk email address. %{linkStart}Learn more.%{linkEnd}',
- )
- "
- >
- <template #link="{ content }">
- <gl-link
- :href="customEmailAddressHelpUrl"
- target="_blank"
- class="gl-text-blue-600 font-size-inherit"
- >{{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </span>
- </template>
</gl-form-group>
<gl-button
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 1ab41ee2f0a..4a130ade631 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -1,6 +1,6 @@
<script>
import { GlLink, GlIcon, GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
import {
issuableIconMap,
linkedIssueTypesMap,
@@ -95,6 +95,16 @@ export default {
required: false,
default: true,
},
+ hasError: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ itemAddFailureMessage: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -120,9 +130,6 @@ export default {
shouldShowTokenBody() {
return this.hasRelatedIssues || this.isFetching;
},
- hasBody() {
- return this.isFormVisible || this.shouldShowTokenBody;
- },
headerText() {
return issuablesBlockHeaderTextMap[this.issuableType];
},
@@ -147,6 +154,11 @@ export default {
toggleLabel() {
return this.isOpen ? __('Collapse') : __('Expand');
},
+ emptyStateMessage() {
+ return this.showCategorizedIssues
+ ? sprintf(this.$options.i18n.emptyItemsPremium, { issuableType: this.issuableType })
+ : sprintf(this.$options.i18n.emptyItemsFree, { issuableType: this.issuableType });
+ },
},
methods: {
handleToggle() {
@@ -158,6 +170,12 @@ export default {
},
},
linkedIssueTypesTextMap,
+ i18n: {
+ emptyItemsFree: __("Link %{issuableType}s together to show that they're related."),
+ emptyItemsPremium: __(
+ "Link %{issuableType}s together to show that they're related or that one is blocking others.",
+ ),
+ },
};
</script>
@@ -166,7 +184,6 @@ export default {
<div class="card card-slim gl-overflow-hidden gl-mt-5 gl-mb-0">
<div
:class="{
- 'panel-empty-heading border-bottom-0': !hasBody,
'gl-border-b-1': isOpen,
'gl-border-b-0': !isOpen,
}"
@@ -180,16 +197,6 @@ export default {
aria-hidden="true"
/>
<slot name="header-text">{{ headerText }}</slot>
- <gl-link
- v-if="hasHelpPath"
- :href="helpPath"
- target="_blank"
- class="gl-display-flex gl-align-items-center gl-ml-2 gl-text-gray-500"
- data-testid="help-link"
- :aria-label="helpLinkText"
- >
- <gl-icon name="question" :size="12" />
- </gl-link>
<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">
@@ -216,7 +223,6 @@ export default {
size="small"
:icon="toggleIcon"
:aria-label="toggleLabel"
- :disabled="!hasRelatedIssues"
data-testid="toggle-links"
@click="handleToggle"
/>
@@ -233,7 +239,7 @@ export default {
<div
v-if="isFormVisible"
class="js-add-related-issues-form-area card-body bordered-box bg-white"
- :class="{ 'gl-mb-5': shouldShowTokenBody }"
+ :class="{ 'gl-mb-5': shouldShowTokenBody, 'gl-show-field-errors': hasError }"
>
<add-issuable-form
:show-categorized-issues="showCategorizedIssues"
@@ -245,6 +251,8 @@ export default {
:auto-complete-epics="autoCompleteEpics"
:auto-complete-issues="autoCompleteIssues"
:path-id-separator="pathIdSeparator"
+ :has-error="hasError"
+ :item-add-failure-message="itemAddFailureMessage"
@pendingIssuableRemoveRequest="$emit('pendingIssuableRemoveRequest', $event)"
@addIssuableFormInput="$emit('addIssuableFormInput', $event)"
@addIssuableFormBlur="$emit('addIssuableFormBlur', $event)"
@@ -269,6 +277,20 @@ export default {
@saveReorder="$emit('saveReorder', $event)"
/>
</template>
+ <div v-if="!shouldShowTokenBody && !isFormVisible" data-testid="related-items-empty">
+ <p class="gl-my-5 gl-px-5">
+ {{ emptyStateMessage }}
+ <gl-link
+ v-if="hasHelpPath"
+ :href="helpPath"
+ target="_blank"
+ data-testid="help-link"
+ :aria-label="helpLinkText"
+ >
+ {{ __('Learn more.') }}
+ </gl-link>
+ </p>
+ </div>
</div>
</div>
</div>
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 38e1d6e9d4f..795eb3b0083 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_root.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue
@@ -107,6 +107,8 @@ export default {
isSubmitting: false,
isFormVisible: false,
inputValue: '',
+ hasError: false,
+ errorMessage: null,
};
},
computed: {
@@ -170,11 +172,11 @@ export default {
this.isFormVisible = false;
})
.catch(({ response }) => {
- let errorMessage = addRelatedIssueErrorMap[this.issuableType];
+ this.hasError = true;
+ this.errorMessage = addRelatedIssueErrorMap[this.issuableType];
if (response && response.data && response.data.message) {
- errorMessage = response.data.message;
+ this.errorMessage = response.data.message;
}
- createAlert({ message: errorMessage });
})
.finally(() => {
this.isSubmitting = false;
@@ -266,6 +268,8 @@ export default {
:issuable-type="issuableType"
:path-id-separator="pathIdSeparator"
:show-categorized-issues="showCategorizedIssues"
+ :has-error="hasError"
+ :item-add-failure-message="errorMessage"
@saveReorder="saveIssueOrder"
@toggleAddRelatedIssuesForm="onToggleAddRelatedIssuesForm"
@addIssuableFormInput="onInput"
diff --git a/app/assets/javascripts/related_issues/constants.js b/app/assets/javascripts/related_issues/constants.js
index 4eb054ccb5c..d1b2d41d7ae 100644
--- a/app/assets/javascripts/related_issues/constants.js
+++ b/app/assets/javascripts/related_issues/constants.js
@@ -111,8 +111,9 @@ export const issuablesBlockHeaderTextMap = {
};
export const issuablesBlockHelpTextMap = {
- [issuableTypesMap.ISSUE]: __('Read more about related issues'),
- [issuableTypesMap.EPIC]: __('Read more about related epics'),
+ [issuableTypesMap.ISSUE]: __('Learn more about linking issues'),
+ [issuableTypesMap.INCIDENT]: __('Learn more about linking issues and incidents'),
+ [issuableTypesMap.EPIC]: __('Learn more about linking epics'),
};
export const issuablesBlockAddButtonTextMap = {
diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js
index eb2f5d119b8..c77a67c4287 100644
--- a/app/assets/javascripts/related_issues/index.js
+++ b/app/assets/javascripts/related_issues/index.js
@@ -17,6 +17,7 @@ export function initRelatedIssues(issueType = 'issue') {
provide: {
fullPath: el.dataset.fullPath,
hasIssueWeightsFeature: parseBoolean(el.dataset.hasIssueWeightsFeature),
+ hasIterationsFeature: parseBoolean(el.dataset.hasIterationsFeature),
},
render: (createElement) =>
createElement(RelatedIssuesRoot, {
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index dd3f4ed636f..965b9fa09d6 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -257,9 +257,9 @@ export default {
<asset-links-form />
- <div class="d-flex pt-3">
+ <div class="d-flex gl-gap-x-3 pt-3">
<gl-button
- class="mr-auto js-no-auto-disable"
+ class="js-no-auto-disable"
category="primary"
variant="confirm"
type="submit"
diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue
index 7c6d44456d9..dc465851721 100644
--- a/app/assets/javascripts/releases/components/asset_links_form.vue
+++ b/app/assets/javascripts/releases/components/asset_links_form.vue
@@ -131,10 +131,10 @@ export default {
<div
v-for="(link, index) in release.assets.links"
:key="link.id"
- class="row flex-column flex-sm-row align-items-stretch align-items-sm-start no-gutters"
+ class="gl-sm-display-flex flex-column flex-sm-row gl-gap-5 align-items-stretch align-items-sm-start no-gutters"
>
<gl-form-group
- class="url-field form-group col pr-sm-2"
+ class="url-field form-group col"
:label="__('URL')"
:label-for="`asset-url-${index}`"
>
@@ -174,7 +174,7 @@ export default {
</gl-form-group>
<gl-form-group
- class="link-title-field col px-sm-2"
+ class="link-title-field col"
:label="__('Link title')"
:label-for="`asset-link-name-${index}`"
>
@@ -201,7 +201,7 @@ export default {
</gl-form-group>
<gl-form-group
- class="link-type-field col-auto px-sm-2"
+ class="link-type-field col-auto"
:label="__('Type')"
:label-for="`asset-type-${index}`"
>
@@ -216,9 +216,8 @@ export default {
/>
</gl-form-group>
- <div class="mb-5 mb-sm-3 mt-sm-4 col col-sm-auto pl-sm-2">
+ <div v-if="release.assets.links.length !== 1" class="mb-5 mb-sm-3 mt-sm-4 col col-sm-auto">
<gl-button
- v-gl-tooltip
class="remove-button w-100 form-control"
:aria-label="__('Remove asset link')"
:title="__('Remove asset link')"
@@ -233,8 +232,9 @@ export default {
</div>
<gl-button
ref="addAnotherLinkButton"
- variant="link"
- class="align-self-end mb-5 mb-sm-0"
+ category="secondary"
+ variant="confirm"
+ class="gl-align-self-start gl-mb-5"
@click="onAddAnotherClicked"
>
{{ __('Add another link') }}
diff --git a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue
deleted file mode 100644
index 599e8d35708..00000000000
--- a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue
+++ /dev/null
@@ -1,84 +0,0 @@
-<script>
-import { mapState, mapActions, mapGetters } from 'vuex';
-import { s__, sprintf } from '~/locale';
-import { componentNames } from '~/reports/components/issue_body';
-import ReportSection from '~/reports/components/report_section.vue';
-import createStore from './store';
-
-export default {
- name: 'GroupedCodequalityReportsApp',
- store: createStore(),
- components: {
- ReportSection,
- },
- props: {
- headBlobPath: {
- type: String,
- required: true,
- },
- baseBlobPath: {
- type: String,
- required: false,
- default: null,
- },
- codequalityReportsPath: {
- type: String,
- required: false,
- default: '',
- },
- codequalityHelpPath: {
- type: String,
- required: true,
- },
- },
- componentNames,
- computed: {
- ...mapState(['newIssues', 'resolvedIssues', 'hasError', 'statusReason']),
- ...mapGetters([
- 'hasCodequalityIssues',
- 'codequalityStatus',
- 'codequalityText',
- 'codequalityPopover',
- ]),
- },
- created() {
- this.setPaths({
- baseBlobPath: this.baseBlobPath,
- headBlobPath: this.headBlobPath,
- reportsPath: this.codequalityReportsPath,
- helpPath: this.codequalityHelpPath,
- });
-
- this.fetchReports();
- },
- methods: {
- ...mapActions(['fetchReports', 'setPaths']),
- },
- loadingText: sprintf(s__('ciReport|Loading %{reportName} report'), {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- reportName: 'Code quality',
- }),
- errorText: sprintf(s__('ciReport|Failed to load %{reportName} report'), {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- reportName: 'Code quality',
- }),
-};
-</script>
-<template>
- <report-section
- :status="codequalityStatus"
- :loading-text="$options.loadingText"
- :error-text="$options.errorText"
- :success-text="codequalityText"
- :unresolved-issues="newIssues"
- :resolved-issues="resolvedIssues"
- :has-issues="hasCodequalityIssues"
- :component="$options.componentNames.CodequalityIssueBody"
- :popover-options="codequalityPopover"
- :show-report-section-status-icon="false"
- track-action="users_expanding_testing_code_quality_report"
- class="js-codequality-widget mr-widget-border-top mr-report"
- >
- <template v-if="hasError" #sub-heading>{{ statusReason }}</template>
- </report-section>
-</template>
diff --git a/app/assets/javascripts/reports/components/issue_body.js b/app/assets/javascripts/reports/components/issue_body.js
index a76a6f45c07..4f418216024 100644
--- a/app/assets/javascripts/reports/components/issue_body.js
+++ b/app/assets/javascripts/reports/components/issue_body.js
@@ -2,12 +2,10 @@ import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
export const components = {
CodequalityIssueBody: () => import('../codequality_report/components/codequality_issue_body.vue'),
- TestIssueBody: () => import('../grouped_test_report/components/test_issue_body.vue'),
};
export const componentNames = {
CodequalityIssueBody: 'CodequalityIssueBody',
- TestIssueBody: 'TestIssueBody',
};
export const iconComponents = {
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index bb86695b9a3..468c8916b8d 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -168,11 +168,6 @@ export default {
},
methods: {
toggleCollapsed() {
- // Because the top-level div is always clickable, we need to check if we can collapse.
- if (!this.isCollapsible) {
- return;
- }
-
if (this.trackAction) {
api.trackRedisHllUserEvent(this.trackAction);
}
@@ -187,7 +182,7 @@ export default {
</script>
<template>
<section class="media-section">
- <div class="media" :class="{ 'gl-cursor-pointer': isCollapsible }" @click="toggleCollapsed">
+ <div class="media">
<status-icon :status="statusIconName" :size="24" class="align-self-center" />
<div class="media-body gl-display-flex gl-align-items-flex-start gl-flex-direction-row!">
<div
@@ -218,7 +213,7 @@ export default {
category="tertiary"
size="small"
:icon="isExpanded ? 'chevron-lg-up' : 'chevron-lg-down'"
- @click.stop="toggleCollapsed"
+ @click="toggleCollapsed"
/>
</div>
</div>
diff --git a/app/assets/javascripts/reports/grouped_test_report/components/modal.vue b/app/assets/javascripts/reports/grouped_test_report/components/modal.vue
deleted file mode 100644
index ca518aea743..00000000000
--- a/app/assets/javascripts/reports/grouped_test_report/components/modal.vue
+++ /dev/null
@@ -1,74 +0,0 @@
-<script>
-import { GlModal, GlLink, GlSprintf } from '@gitlab/ui';
-
-import CodeBlock from '~/vue_shared/components/code_block.vue';
-import { fieldTypes } from '../../constants';
-
-export default {
- components: {
- CodeBlock,
- GlModal,
- GlLink,
- GlSprintf,
- },
- props: {
- visible: {
- type: Boolean,
- required: true,
- },
- title: {
- type: String,
- required: true,
- },
- modalData: {
- type: Object,
- required: true,
- },
- },
- computed: {
- filteredModalData() {
- // Filter out the properties that don't have a value
- return Object.fromEntries(
- Object.entries(this.modalData).filter((data) => Boolean(data[1].value)),
- );
- },
- },
- fieldTypes,
-};
-</script>
-<template>
- <gl-modal
- :visible="visible"
- modal-id="modal-mrwidget-reports"
- :title="title"
- :hide-footer="true"
- @hide="$emit('hide')"
- >
- <div v-for="(field, key, index) in filteredModalData" :key="index" class="row gl-mt-3 gl-mb-3">
- <strong class="col-sm-3 text-right"> {{ field.text }}: </strong>
-
- <div class="col-sm-9">
- <code-block v-if="field.type === $options.fieldTypes.codeBlock" :code="field.value" />
-
- <gl-link
- v-else-if="field.type === $options.fieldTypes.link"
- :href="field.value.path"
- target="_blank"
- >
- {{ field.value.text }}
- </gl-link>
-
- <gl-sprintf
- v-else-if="field.type === $options.fieldTypes.seconds"
- :message="__('%{value} s')"
- >
- <template #value>{{ field.value }}</template>
- </gl-sprintf>
-
- <template v-else-if="field.type === $options.fieldTypes.text">
- {{ field.value }}
- </template>
- </div>
- </div>
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue b/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue
deleted file mode 100644
index 8913046d62f..00000000000
--- a/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue
+++ /dev/null
@@ -1,64 +0,0 @@
-<script>
-import { GlBadge, GlButton } from '@gitlab/ui';
-import { mapActions } from 'vuex';
-import { sprintf, n__ } from '~/locale';
-import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
-import { STATUS_NEUTRAL } from '../../constants';
-
-export default {
- name: 'TestIssueBody',
- components: {
- GlBadge,
- GlButton,
- IssueStatusIcon,
- },
- props: {
- issue: {
- type: Object,
- required: true,
- },
- },
- computed: {
- recentFailureMessage() {
- return sprintf(
- n__(
- 'Reports|Failed %{count} time in %{base_branch} in the last 14 days',
- 'Reports|Failed %{count} times in %{base_branch} in the last 14 days',
- this.issue.recent_failures?.count,
- ),
- this.issue.recent_failures,
- );
- },
- showRecentFailures() {
- return this.issue.recent_failures?.count && this.issue.recent_failures?.base_branch;
- },
- status() {
- return this.issue.status || STATUS_NEUTRAL;
- },
- },
- methods: {
- ...mapActions(['openModal']),
- },
-};
-</script>
-<template>
- <div class="gl-display-flex gl-mt-2 gl-mb-2">
- <issue-status-icon :status="status" :status-icon-size="24" class="gl-mr-3" />
- <gl-button
- button-text-classes="gl-white-space-normal! gl-word-break-all gl-text-left"
- variant="link"
- data-testid="test-issue-body-description"
- @click="openModal({ issue })"
- >
- <gl-badge
- v-if="showRecentFailures"
- variant="warning"
- class="gl-mr-2"
- data-testid="test-issue-body-recent-failures"
- >
- {{ recentFailureMessage }}
- </gl-badge>
- {{ issue.name }}
- </gl-button>
- </div>
-</template>
diff --git a/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue b/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue
deleted file mode 100644
index be49a03a9a5..00000000000
--- a/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue
+++ /dev/null
@@ -1,204 +0,0 @@
-<script>
-import { GlButton, GlIcon } from '@gitlab/ui';
-import { mapActions, mapGetters, mapState } from 'vuex';
-import api from '~/api';
-import { sprintf, s__ } from '~/locale';
-import GroupedIssuesList from '../components/grouped_issues_list.vue';
-import { componentNames } from '../components/issue_body';
-import ReportSection from '../components/report_section.vue';
-import SummaryRow from '../components/summary_row.vue';
-import Modal from './components/modal.vue';
-import createStore from './store';
-import {
- summaryTextBuilder,
- reportTextBuilder,
- statusIcon,
- recentFailuresTextBuilder,
-} from './store/utils';
-
-export default {
- name: 'GroupedTestReportsApp',
- store: createStore(),
- components: {
- ReportSection,
- SummaryRow,
- GroupedIssuesList,
- Modal,
- GlButton,
- GlIcon,
- },
- props: {
- endpoint: {
- type: String,
- required: true,
- },
- pipelinePath: {
- type: String,
- required: false,
- default: '',
- },
- headBlobPath: {
- type: String,
- required: true,
- },
- },
- componentNames,
- computed: {
- ...mapState(['reports', 'isLoading', 'hasError', 'summary']),
- ...mapState({
- modalTitle: (state) => state.modal.title || '',
- modalData: (state) => state.modal.data || {},
- modalOpen: (state) => state.modal.open || false,
- }),
- ...mapGetters(['summaryStatus']),
- groupedSummaryText() {
- if (this.isLoading) {
- return s__('Reports|Test summary results are being parsed');
- }
-
- if (this.hasError) {
- return s__('Reports|Test summary failed loading results');
- }
-
- return summaryTextBuilder(s__('Reports|Test summary'), this.summary);
- },
- testTabURL() {
- return `${this.pipelinePath}/test_report`;
- },
- showViewFullReport() {
- return this.pipelinePath.length;
- },
- },
- created() {
- this.setPaths({
- endpoint: this.endpoint,
- headBlobPath: this.headBlobPath,
- });
-
- this.fetchReports();
- },
- methods: {
- ...mapActions(['setPaths', 'fetchReports', 'closeModal']),
- handleToggleEvent() {
- api.trackRedisHllUserEvent(this.$options.expandEvent);
- },
- reportText(report) {
- const { name, summary } = report || {};
-
- if (report.status === 'error') {
- return sprintf(s__('Reports|An error occurred while loading %{name} results'), { name });
- }
-
- if (!report.name) {
- return s__('Reports|An error occurred while loading report');
- }
-
- return reportTextBuilder(name, summary);
- },
- hasRecentFailures(summary) {
- return summary?.recentlyFailed > 0;
- },
- recentFailuresText(summary) {
- return recentFailuresTextBuilder(summary);
- },
- getReportIcon(report) {
- return statusIcon(report.status);
- },
- shouldRenderIssuesList(report) {
- return (
- report.existing_failures.length > 0 ||
- report.new_failures.length > 0 ||
- report.resolved_failures.length > 0 ||
- report.existing_errors.length > 0 ||
- report.new_errors.length > 0 ||
- report.resolved_errors.length > 0
- );
- },
- unresolvedIssues(report) {
- return [
- ...report.new_failures,
- ...report.new_errors,
- ...report.existing_failures,
- ...report.existing_errors,
- ];
- },
- resolvedIssues(report) {
- return report.resolved_failures.concat(report.resolved_errors);
- },
- },
- expandEvent: 'i_testing_summary_widget_total',
-};
-</script>
-<template>
- <report-section
- :status="summaryStatus"
- :success-text="groupedSummaryText"
- :loading-text="groupedSummaryText"
- :error-text="groupedSummaryText"
- :has-issues="reports.length > 0"
- :should-emit-toggle-event="true"
- class="mr-widget-section grouped-security-reports mr-report"
- @toggleEvent.once="handleToggleEvent"
- >
- <template v-if="showViewFullReport" #action-buttons>
- <gl-button
- :href="testTabURL"
- target="_blank"
- icon="external-link"
- data-testid="group-test-reports-full-link"
- class="gl-mr-3"
- >
- {{ s__('ciReport|View full report') }}
- </gl-button>
- </template>
- <template v-if="hasRecentFailures(summary)" #sub-heading>
- {{ recentFailuresText(summary) }}
- </template>
- <template #body>
- <div class="mr-widget-grouped-section report-block">
- <template v-for="(report, i) in reports">
- <summary-row
- :key="`summary-row-${i}`"
- :status-icon="getReportIcon(report)"
- nested-summary
- >
- <template #summary>
- <div class="gl-display-inline-flex gl-flex-direction-column">
- <div>{{ reportText(report) }}</div>
- <div v-if="report.suite_errors">
- <div v-if="report.suite_errors.head">
- <gl-icon name="warning" class="gl-mx-2 gl-text-orange-500" />
- {{ s__('Reports|Head report parsing error:') }}
- {{ report.suite_errors.head }}
- </div>
- <div v-if="report.suite_errors.base">
- <gl-icon name="warning" class="gl-mx-2 gl-text-orange-500" />
- {{ s__('Reports|Base report parsing error:') }}
- {{ report.suite_errors.base }}
- </div>
- </div>
- <div v-if="hasRecentFailures(report.summary)">
- {{ recentFailuresText(report.summary) }}
- </div>
- </div>
- </template>
- </summary-row>
- <grouped-issues-list
- v-if="shouldRenderIssuesList(report)"
- :key="`issues-list-${i}`"
- :unresolved-issues="unresolvedIssues(report)"
- :resolved-issues="resolvedIssues(report)"
- :component="$options.componentNames.TestIssueBody"
- :nested-level="2"
- />
- </template>
- <modal
- :visible="modalOpen"
- :title="modalTitle"
- :modal-data="modalData"
- @hide="closeModal"
- />
- </div>
- </template>
- </report-section>
-</template>
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/actions.js b/app/assets/javascripts/reports/grouped_test_report/store/actions.js
deleted file mode 100644
index 73f8df016b6..00000000000
--- a/app/assets/javascripts/reports/grouped_test_report/store/actions.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import Visibility from 'visibilityjs';
-import axios from '~/lib/utils/axios_utils';
-import httpStatusCodes from '~/lib/utils/http_status';
-import Poll from '~/lib/utils/poll';
-import * as types from './mutation_types';
-
-export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths);
-
-export const requestReports = ({ commit }) => commit(types.REQUEST_REPORTS);
-
-let eTagPoll;
-
-export const clearEtagPoll = () => {
- eTagPoll = null;
-};
-
-export const stopPolling = () => {
- if (eTagPoll) eTagPoll.stop();
-};
-
-export const restartPolling = () => {
- if (eTagPoll) eTagPoll.restart();
-};
-
-/**
- * We need to poll the reports endpoint while they are being parsed in the Backend.
- * This can take up to one minute.
- *
- * Poll.js will handle etag response.
- * While http status code is 204, it means it's parsing, and we'll keep polling
- * When http status code is 200, it means parsing is done, we can show the results & stop polling
- * When http status code is 500, it means parsing went wrong and we stop polling
- */
-export const fetchReports = ({ state, dispatch }) => {
- dispatch('requestReports');
-
- eTagPoll = new Poll({
- resource: {
- getReports(endpoint) {
- return axios.get(endpoint);
- },
- },
- data: state.endpoint,
- method: 'getReports',
- successCallback: ({ data, status }) =>
- dispatch('receiveReportsSuccess', {
- data,
- status,
- }),
- errorCallback: () => dispatch('receiveReportsError'),
- });
-
- if (!Visibility.hidden()) {
- eTagPoll.makeRequest();
- } else {
- axios
- .get(state.endpoint)
- .then(({ data, status }) => dispatch('receiveReportsSuccess', { data, status }))
- .catch(() => dispatch('receiveReportsError'));
- }
-
- Visibility.change(() => {
- if (!Visibility.hidden()) {
- dispatch('restartPolling');
- } else {
- dispatch('stopPolling');
- }
- });
-};
-
-export const receiveReportsSuccess = ({ commit }, response) => {
- // With 204 we keep polling and don't update the state
- if (response.status === httpStatusCodes.OK) {
- commit(types.RECEIVE_REPORTS_SUCCESS, response.data);
- }
-};
-
-export const receiveReportsError = ({ commit }) => commit(types.RECEIVE_REPORTS_ERROR);
-
-export const openModal = ({ commit }, payload) => commit(types.SET_ISSUE_MODAL_DATA, payload);
-
-export const closeModal = ({ commit }, payload) => commit(types.RESET_ISSUE_MODAL_DATA, payload);
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/getters.js b/app/assets/javascripts/reports/grouped_test_report/store/getters.js
deleted file mode 100644
index e7d1675f74a..00000000000
--- a/app/assets/javascripts/reports/grouped_test_report/store/getters.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '../../constants';
-
-export const summaryStatus = (state) => {
- if (state.isLoading) {
- return LOADING;
- }
-
- if (state.hasError || state.status === STATUS_FAILED) {
- return ERROR;
- }
-
- return SUCCESS;
-};
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/index.js b/app/assets/javascripts/reports/grouped_test_report/store/index.js
deleted file mode 100644
index a2edfa94a48..00000000000
--- a/app/assets/javascripts/reports/grouped_test_report/store/index.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import * as actions from './actions';
-import * as getters from './getters';
-import mutations from './mutations';
-import state from './state';
-
-Vue.use(Vuex);
-
-export const getStoreConfig = () => ({
- actions,
- mutations,
- getters,
- state: state(),
-});
-
-export default () => new Vuex.Store(getStoreConfig());
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js b/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js
deleted file mode 100644
index ff839c564b6..00000000000
--- a/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js
+++ /dev/null
@@ -1,7 +0,0 @@
-export const SET_PATHS = 'SET_PATHS';
-
-export const REQUEST_REPORTS = 'REQUEST_REPORTS';
-export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS';
-export const RECEIVE_REPORTS_ERROR = 'RECEIVE_REPORTS_ERROR';
-export const SET_ISSUE_MODAL_DATA = 'SET_ISSUE_MODAL_DATA';
-export const RESET_ISSUE_MODAL_DATA = 'RESET_ISSUE_MODAL_DATA';
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/mutations.js b/app/assets/javascripts/reports/grouped_test_report/store/mutations.js
deleted file mode 100644
index 2b88776815b..00000000000
--- a/app/assets/javascripts/reports/grouped_test_report/store/mutations.js
+++ /dev/null
@@ -1,79 +0,0 @@
-import * as types from './mutation_types';
-import { countRecentlyFailedTests, formatFilePath } from './utils';
-
-export default {
- [types.SET_PATHS](state, { endpoint, headBlobPath }) {
- state.endpoint = endpoint;
- state.headBlobPath = headBlobPath;
- },
- [types.REQUEST_REPORTS](state) {
- state.isLoading = true;
- },
- [types.RECEIVE_REPORTS_SUCCESS](state, response) {
- state.hasError = response.suites.some((suite) => suite.status === 'error');
-
- state.isLoading = false;
-
- state.summary.total = response.summary.total;
- state.summary.resolved = response.summary.resolved;
- state.summary.failed = response.summary.failed;
- state.summary.errored = response.summary.errored;
- state.summary.recentlyFailed = countRecentlyFailedTests(response.suites);
-
- state.status = response.status;
- state.reports = response.suites;
-
- state.reports.forEach((report, i) => {
- if (!state.reports[i].summary) return;
- state.reports[i].summary.recentlyFailed = countRecentlyFailedTests(report);
- });
- },
- [types.RECEIVE_REPORTS_ERROR](state) {
- state.isLoading = false;
- state.hasError = true;
-
- state.reports = [];
- state.summary = {
- total: 0,
- resolved: 0,
- failed: 0,
- errored: 0,
- recentlyFailed: 0,
- };
- state.status = null;
- },
- [types.SET_ISSUE_MODAL_DATA](state, payload) {
- const { issue } = payload;
- state.modal.title = issue.name;
-
- Object.keys(issue).forEach((key) => {
- if (Object.prototype.hasOwnProperty.call(state.modal.data, key)) {
- state.modal.data[key] = {
- ...state.modal.data[key],
- value: issue[key],
- };
- }
- });
-
- if (issue.file) {
- state.modal.data.filename.value = {
- text: issue.file,
- path: `${state.headBlobPath}/${formatFilePath(issue.file)}`,
- };
- }
-
- state.modal.open = true;
- },
- [types.RESET_ISSUE_MODAL_DATA](state) {
- state.modal.open = false;
-
- // Resetting modal data
- state.modal.title = null;
- Object.keys(state.modal.data).forEach((key) => {
- state.modal.data[key] = {
- ...state.modal.data[key],
- value: null,
- };
- });
- },
-};
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/state.js b/app/assets/javascripts/reports/grouped_test_report/store/state.js
deleted file mode 100644
index 46909bde337..00000000000
--- a/app/assets/javascripts/reports/grouped_test_report/store/state.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import { s__ } from '~/locale';
-import { fieldTypes } from '../../constants';
-
-export default () => ({
- endpoint: null,
-
- isLoading: false,
- hasError: false,
-
- status: null,
-
- summary: {
- total: 0,
- resolved: 0,
- failed: 0,
- errored: 0,
- },
-
- /**
- * Each report will have the following format:
- * {
- * name: {String},
- * summary: {
- * total: {Number},
- * resolved: {Number},
- * failed: {Number},
- * errored: {Number},
- * },
- * new_failures: {Array.<Object>},
- * resolved_failures: {Array.<Object>},
- * existing_failures: {Array.<Object>},
- * new_errors: {Array.<Object>},
- * resolved_errors: {Array.<Object>},
- * existing_errors: {Array.<Object>},
- * }
- */
- reports: [],
-
- modal: {
- title: null,
- open: false,
-
- data: {
- classname: {
- value: null,
- text: s__('Reports|Classname'),
- type: fieldTypes.text,
- },
- filename: {
- value: null,
- text: s__('Reports|Filename'),
- type: fieldTypes.link,
- },
- execution_time: {
- value: null,
- text: s__('Reports|Execution time'),
- type: fieldTypes.seconds,
- },
- failure: {
- value: null,
- text: s__('Reports|Failure'),
- type: fieldTypes.codeBlock,
- },
- system_output: {
- value: null,
- text: s__('Reports|System output'),
- type: fieldTypes.codeBlock,
- },
- },
- },
-});
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/utils.js b/app/assets/javascripts/reports/grouped_test_report/store/utils.js
deleted file mode 100644
index df5dd73b66c..00000000000
--- a/app/assets/javascripts/reports/grouped_test_report/store/utils.js
+++ /dev/null
@@ -1,111 +0,0 @@
-import { sprintf, n__, s__, __ } from '~/locale';
-import {
- STATUS_FAILED,
- STATUS_SUCCESS,
- ICON_WARNING,
- ICON_SUCCESS,
- ICON_NOTFOUND,
-} from '../../constants';
-
-const textBuilder = (results) => {
- const { failed, errored, resolved, total } = results;
-
- const failedOrErrored = (failed || 0) + (errored || 0);
- const failedString = failed ? n__('%d failed', '%d failed', failed) : null;
- const erroredString = errored ? n__('%d error', '%d errors', errored) : null;
- const combinedString =
- failed && errored ? `${failedString}, ${erroredString}` : failedString || erroredString;
- const resolvedString = resolved
- ? n__('%d fixed test result', '%d fixed test results', resolved)
- : null;
- const totalString = total ? n__('out of %d total test', 'out of %d total tests', total) : null;
-
- let resultsString = s__('Reports|no changed test results');
-
- if (failedOrErrored) {
- if (resolved) {
- resultsString = sprintf(s__('Reports|%{combinedString} and %{resolvedString}'), {
- combinedString,
- resolvedString,
- });
- } else {
- resultsString = combinedString;
- }
- } else if (resolved) {
- resultsString = resolvedString;
- }
-
- return `${resultsString} ${totalString}`;
-};
-
-export const summaryTextBuilder = (name = '', results = {}) => {
- const resultsString = textBuilder(results);
- return sprintf(__('%{name} contained %{resultsString}'), { name, resultsString });
-};
-
-export const reportTextBuilder = (name = '', results = {}) => {
- const resultsString = textBuilder(results);
- return sprintf(__('%{name} found %{resultsString}'), { name, resultsString });
-};
-
-export const recentFailuresTextBuilder = (summary = {}) => {
- const { failed, recentlyFailed } = summary;
- if (!failed || !recentlyFailed) return '';
-
- if (failed < 2) {
- return sprintf(
- s__(
- 'Reports|%{recentlyFailed} out of %{failed} failed test has failed more than once in the last 14 days',
- ),
- { recentlyFailed, failed },
- );
- }
- return sprintf(
- n__(
- 'Reports|%{recentlyFailed} out of %{failed} failed tests has failed more than once in the last 14 days',
- 'Reports|%{recentlyFailed} out of %{failed} failed tests have failed more than once in the last 14 days',
- recentlyFailed,
- ),
- { recentlyFailed, failed },
- );
-};
-
-export const countRecentlyFailedTests = (subject) => {
- // handle either a single report or an array of reports
- const reports = !subject.length ? [subject] : subject;
-
- return reports
- .map((report) => {
- return (
- [report.new_failures, report.existing_failures, report.resolved_failures]
- // only count tests which have failed more than once
- .map(
- (failureArray) =>
- failureArray.filter((failure) => failure.recent_failures?.count > 1).length,
- )
- .reduce((total, count) => total + count, 0)
- );
- })
- .reduce((total, count) => total + count, 0);
-};
-
-export const statusIcon = (status) => {
- if (status === STATUS_FAILED) {
- return ICON_WARNING;
- }
-
- if (status === STATUS_SUCCESS) {
- return ICON_SUCCESS;
- }
-
- return ICON_NOTFOUND;
-};
-
-/**
- * Removes `./` from the beginning of a file path so it can be appended onto a blob path
- * @param {String} file
- * @returns {String} - formatted value
- */
-export const formatFilePath = (file) => {
- return file.replace(/^\.?\/*/, '');
-};
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index bf1667d8734..101625a4b72 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -14,7 +14,7 @@ import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
import CodeIntelligence from '~/code_navigation/components/app.vue';
import LineHighlighter from '~/blob/line_highlighter';
import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql';
-import addBlameLink from '~/blob/blob_blame_link';
+import { addBlameLink } from '~/blob/blob_blame_link';
import projectInfoQuery from '../queries/project_info.query.graphql';
import getRefMixin from '../mixins/get_ref';
import userInfoQuery from '../queries/user_info.query.graphql';
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index 77d3a517d28..3a6d7d2f779 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -93,13 +93,9 @@ export const LFS_STORAGE = 'lfs';
* These are file types that we want the legacy (backend) syntax highlighter to highlight.
*/
export const LEGACY_FILE_TYPES = [
- 'gemfile',
- 'composer_json',
'podfile',
'podspec',
- 'podspec_json',
'cartfile',
- 'godeps_json',
'requirements_txt',
'cargo_toml',
'go_mod',
diff --git a/app/assets/javascripts/runner/components/runner_stacked_layout_banner.vue b/app/assets/javascripts/runner/components/runner_stacked_layout_banner.vue
deleted file mode 100644
index e3a9a9fd8a4..00000000000
--- a/app/assets/javascripts/runner/components/runner_stacked_layout_banner.vue
+++ /dev/null
@@ -1,58 +0,0 @@
-<script>
-import allChangesCommittedSvg from '@gitlab/svgs/dist/illustrations/multi-editor_all_changes_committed_empty.svg';
-import { GlBanner } from '@gitlab/ui';
-
-import { s__ } from '~/locale';
-import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-
-const I18N_TITLE = s__("Runners|We've made some changes and want your feedback");
-const I18N_DESCRIPTION = s__(
- "Runners|We want you to be able to manage your runners easily and efficiently from this page, and we are making changes to get there. Give us feedback on how we're doing!",
-);
-const I18N_LINK = s__('Runners|Add your feedback in the issue');
-
-// use a data url instead getting it from via HTML data-* attributes to simplify removal of this feature flag
-const ILLUSTRATION_URL = `data:image/svg+xml;utf8,${encodeURIComponent(allChangesCommittedSvg)}`;
-const ISSUE_URL = 'https://gitlab.com/gitlab-org/gitlab/-/issues/371621';
-const STORAGE_KEY = 'runner_list_stacked_layout_feedback_dismissed';
-
-export default {
- components: {
- GlBanner,
- LocalStorageSync,
- },
- data() {
- return {
- isDismissed: false,
- };
- },
- methods: {
- onClose() {
- this.isDismissed = true;
- },
- },
- I18N_TITLE,
- I18N_DESCRIPTION,
- I18N_LINK,
- ILLUSTRATION_URL,
- ISSUE_URL,
- STORAGE_KEY,
-};
-</script>
-
-<template>
- <div>
- <local-storage-sync v-model="isDismissed" :storage-key="$options.STORAGE_KEY" />
- <gl-banner
- v-if="!isDismissed"
- :svg-path="$options.ILLUSTRATION_URL"
- :title="$options.I18N_TITLE"
- :button-text="$options.I18N_LINK"
- :button-link="$options.ISSUE_URL"
- class="gl-my-5"
- @close="onClose"
- >
- <p>{{ $options.I18N_DESCRIPTION }}</p>
- </gl-banner>
- </div>
-</template>
diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue
index 789efc8f09d..6f29864c0a2 100644
--- a/app/assets/javascripts/search/sidebar/components/app.vue
+++ b/app/assets/javascripts/search/sidebar/components/app.vue
@@ -1,48 +1,29 @@
<script>
-import { GlButton, GlLink } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
-import ConfidentialityFilter from './confidentiality_filter.vue';
-import StatusFilter from './status_filter.vue';
+import { mapState } from 'vuex';
+import ScopeNavigation from '~/search/sidebar/components/scope_navigation.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS } from '../constants';
+import ResultsFilters from './results_filters.vue';
export default {
name: 'GlobalSearchSidebar',
components: {
- GlButton,
- GlLink,
- StatusFilter,
- ConfidentialityFilter,
+ ResultsFilters,
+ ScopeNavigation,
},
+ mixins: [glFeatureFlagsMixin()],
computed: {
- ...mapState(['urlQuery', 'sidebarDirty']),
- showReset() {
- return this.urlQuery.state || this.urlQuery.confidential;
+ ...mapState(['urlQuery']),
+ showFilters() {
+ return this.urlQuery.scope === SCOPE_ISSUES || this.urlQuery.scope === SCOPE_MERGE_REQUESTS;
},
- showSidebar() {
- return this.urlQuery.scope === 'issues' || this.urlQuery.scope === 'merge_requests';
- },
- },
- methods: {
- ...mapActions(['applyQuery', 'resetQuery']),
},
};
</script>
<template>
- <form
- class="search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4 gl-mb-6 gl-mt-5"
- @submit.prevent="applyQuery"
- >
- <template v-if="showSidebar">
- <status-filter />
- <confidentiality-filter />
- <div class="gl-display-flex gl-align-items-center gl-mt-3">
- <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty">
- {{ __('Apply') }}
- </gl-button>
- <gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{
- __('Reset filters')
- }}</gl-link>
- </div>
- </template>
- </form>
+ <section class="search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4 gl-mb-6 gl-mt-5">
+ <scope-navigation v-if="glFeatures.searchPageVerticalNav" />
+ <results-filters v-if="showFilters" />
+ </section>
</template>
diff --git a/app/assets/javascripts/search/sidebar/components/results_filters.vue b/app/assets/javascripts/search/sidebar/components/results_filters.vue
new file mode 100644
index 00000000000..5b53f94bb53
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/results_filters.vue
@@ -0,0 +1,49 @@
+<script>
+import { GlButton, GlLink } from '@gitlab/ui';
+import { mapActions, mapState } from 'vuex';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import ConfidentialityFilter from './confidentiality_filter.vue';
+import StatusFilter from './status_filter.vue';
+
+export default {
+ name: 'ResultsFilters',
+ components: {
+ GlButton,
+ GlLink,
+ StatusFilter,
+ ConfidentialityFilter,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ computed: {
+ ...mapState(['urlQuery', 'sidebarDirty']),
+ showReset() {
+ return this.urlQuery.state || this.urlQuery.confidential;
+ },
+ searchPageVerticalNavFeatureFlag() {
+ return this.glFeatures.searchPageVerticalNav;
+ },
+ },
+ methods: {
+ ...mapActions(['applyQuery', 'resetQuery']),
+ },
+};
+</script>
+
+<template>
+ <form
+ :class="searchPageVerticalNavFeatureFlag ? 'gl-px-5' : 'gl-px-0'"
+ @submit.prevent="applyQuery"
+ >
+ <hr v-if="searchPageVerticalNavFeatureFlag" class="gl-my-5 gl-border-gray-100" />
+ <status-filter />
+ <confidentiality-filter />
+ <div class="gl-display-flex gl-align-items-center gl-mt-4">
+ <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty">
+ {{ __('Apply') }}
+ </gl-button>
+ <gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{
+ __('Reset filters')
+ }}</gl-link>
+ </div>
+ </form>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
new file mode 100644
index 00000000000..f5e1525090e
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlNav, GlNavItem } from '@gitlab/ui';
+import { mapActions, mapState } from 'vuex';
+import { formatNumber } from '~/locale';
+import Tracking from '~/tracking';
+import { NAV_LINK_DEFAULT_CLASSES, NUMBER_FORMATING_OPTIONS } from '../constants';
+
+export default {
+ name: 'ScopeNavigation',
+ components: {
+ GlNav,
+ GlNavItem,
+ },
+ mixins: [Tracking.mixin()],
+ computed: {
+ ...mapState(['navigation', 'urlQuery']),
+ },
+ created() {
+ this.fetchSidebarCount();
+ },
+ methods: {
+ ...mapActions(['fetchSidebarCount']),
+ activeClasses(currentScope) {
+ return currentScope === this.urlQuery.scope ? 'gl-font-weight-bold' : '';
+ },
+ showFormatedCount(count) {
+ if (!count) {
+ return '0';
+ }
+ const countNumber = parseInt(count.replace(/,/g, ''), 10);
+ return formatNumber(countNumber, NUMBER_FORMATING_OPTIONS);
+ },
+ handleClick(scope) {
+ this.track('click_menu_item', { label: `vertical_navigation_${scope}` });
+ },
+ linkClasses(scope) {
+ return [
+ { 'gl-font-weight-bold': scope === this.urlQuery.scope },
+ ...this.$options.NAV_LINK_DEFAULT_CLASSES,
+ ];
+ },
+ },
+ NAV_LINK_DEFAULT_CLASSES,
+};
+</script>
+
+<template>
+ <nav data-testid="search-filter">
+ <gl-nav vertical pills>
+ <gl-nav-item
+ v-for="(item, scope, index) in navigation"
+ :key="scope"
+ :link-classes="linkClasses(scope)"
+ class="gl-mb-1"
+ :href="item.link"
+ :active="urlQuery.scope ? urlQuery.scope === scope : index === 0"
+ @click="handleClick(scope)"
+ ><span>{{ item.label }}</span
+ ><span v-if="item.count" class="gl-font-sm gl-font-weight-normal">
+ {{ showFormatedCount(item.count) }}
+ </span>
+ </gl-nav-item>
+ </gl-nav>
+ <hr class="gl-mt-5 gl-mb-0 gl-border-gray-100 gl-md-display-none" />
+ </nav>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/constants/index.js b/app/assets/javascripts/search/sidebar/constants/index.js
new file mode 100644
index 00000000000..3621138afe4
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/constants/index.js
@@ -0,0 +1,11 @@
+export const SCOPE_ISSUES = 'issues';
+export const SCOPE_MERGE_REQUESTS = 'merge_requests';
+
+export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' };
+export const NAV_LINK_DEFAULT_CLASSES = [
+ 'gl-display-flex',
+ 'gl-flex-direction-row',
+ 'gl-flex-wrap-nowrap',
+ 'gl-justify-content-space-between',
+ 'gl-text-gray-900',
+];
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
index be5742e5949..2a1b744561d 100644
--- a/app/assets/javascripts/search/store/actions.js
+++ b/app/assets/javascripts/search/store/actions.js
@@ -1,6 +1,8 @@
import Api from '~/api';
import { createAlert } from '~/flash';
+import axios from '~/lib/utils/axios_utils';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
+import { logError } from '~/lib/logger';
import { __ } from '~/locale';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants';
import * as types from './mutation_types';
@@ -99,3 +101,19 @@ export const applyQuery = ({ state }) => {
export const resetQuery = ({ state }) => {
visitUrl(setUrlParams({ ...state.query, page: null, state: null, confidential: null }));
};
+
+export const fetchSidebarCount = ({ commit, state }) => {
+ const promises = Object.keys(state.navigation).map((scope) => {
+ // active nav item has count already so we skip it
+ if (scope !== state.urlQuery.scope) {
+ return axios
+ .get(state.navigation[scope].count_link)
+ .then(({ data: { count } }) => {
+ commit(types.RECEIVE_NAVIGATION_COUNT, { key: scope, count });
+ })
+ .catch((e) => logError(e));
+ }
+ return Promise.resolve();
+ });
+ return Promise.all(promises);
+};
diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js
index 4fa88822722..e20a43808cf 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 }) => ({
+export const getStoreConfig = ({ query, navigation }) => ({
actions,
getters,
mutations,
- state: createState({ query }),
+ state: createState({ query, navigation }),
});
const createStore = (config) => new Vuex.Store(getStoreConfig(config));
diff --git a/app/assets/javascripts/search/store/mutation_types.js b/app/assets/javascripts/search/store/mutation_types.js
index bf1e3e79cba..511b93cad2b 100644
--- a/app/assets/javascripts/search/store/mutation_types.js
+++ b/app/assets/javascripts/search/store/mutation_types.js
@@ -10,3 +10,4 @@ export const SET_QUERY = 'SET_QUERY';
export const SET_SIDEBAR_DIRTY = 'SET_SIDEBAR_DIRTY';
export const LOAD_FREQUENT_ITEMS = 'LOAD_FREQUENT_ITEMS';
+export const RECEIVE_NAVIGATION_COUNT = 'RECEIVE_NAVIGATION_COUNT';
diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js
index 5d154fe3aa0..c1339845272 100644
--- a/app/assets/javascripts/search/store/mutations.js
+++ b/app/assets/javascripts/search/store/mutations.js
@@ -32,4 +32,8 @@ export default {
[types.LOAD_FREQUENT_ITEMS](state, { key, data }) {
state.frequentItems[key] = data;
},
+ [types.RECEIVE_NAVIGATION_COUNT](state, { key, count }) {
+ const item = { ...state.navigation[key], count };
+ state.navigation = { ...state.navigation, [key]: item };
+ },
};
diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js
index d4005697f35..b64231a8688 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 }) => ({
+const createState = ({ query, navigation }) => ({
urlQuery: cloneDeep(query),
query,
groups: [],
@@ -13,5 +13,6 @@ const createState = ({ query }) => ({
[PROJECTS_LOCAL_STORAGE_KEY]: [],
},
sidebarDirty: false,
+ navigation,
});
export default createState;
diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
index b14e816a674..ffba3aac681 100644
--- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
+++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
@@ -29,7 +29,7 @@ export default {
SafeHtml: GlSafeHtmlDirective,
},
formLabels: {
- createProject: __('Self monitoring'),
+ createProject: __('Self-monitoring'),
},
data() {
return {
@@ -60,7 +60,7 @@ export default {
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.',
+ 'SelfMonitoring|Self-monitoring is active. Use the %{projectLinkStart}self-monitoring project%{projectLinkEnd} to monitor the health of your instance.',
),
{
projectLinkStart: `<a href="${this.selfMonitorProjectFullUrl}">`,
@@ -71,7 +71,7 @@ export default {
}
return s__(
- 'SelfMonitoring|Activate self monitoring to create a project to use to monitor the health of your instance.',
+ 'SelfMonitoring|Activate self-monitoring to create a project to use to monitor the health of your instance.',
);
},
helpDocsPath() {
@@ -139,11 +139,11 @@ export default {
<h4
class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only"
>
- {{ s__('SelfMonitoring|Self monitoring') }}
+ {{ 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.') }}
+ {{ s__('SelfMonitoring|Activate or deactivate instance self-monitoring.') }}
<gl-link :href="helpDocsPath">{{ __('Learn more.') }}</gl-link>
</p>
</div>
@@ -160,9 +160,9 @@ export default {
</form>
</div>
<gl-modal
- :title="s__('SelfMonitoring|Deactivate self monitoring?')"
+ :title="s__('SelfMonitoring|Deactivate self-monitoring?')"
:modal-id="modalId"
- :ok-title="__('Delete self monitoring project')"
+ :ok-title="__('Delete self-monitoring project')"
:cancel-title="__('Cancel')"
ok-variant="danger"
category="primary"
@@ -172,7 +172,7 @@ export default {
<div>
{{
s__(
- 'SelfMonitoring|Deactivating self monitoring deletes the self monitoring project. Are you sure you want to deactivate self monitoring and delete the project?',
+ 'SelfMonitoring|Deactivating self-monitoring deletes the self-monitoring project. Are you sure you want to deactivate self-monitoring and delete the project?',
)
}}
</div>
diff --git a/app/assets/javascripts/self_monitor/store/actions.js b/app/assets/javascripts/self_monitor/store/actions.js
index f37b654b00a..5b9e994290c 100644
--- a/app/assets/javascripts/self_monitor/store/actions.js
+++ b/app/assets/javascripts/self_monitor/store/actions.js
@@ -56,7 +56,7 @@ export const requestCreateProjectSuccess = ({ commit, dispatch }, selfMonitorDat
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.'),
+ message: s__('SelfMonitoring|Self-monitoring project successfully created.'),
actionText: __('View project'),
actionName: 'viewSelfMonitorProject',
});
@@ -108,7 +108,7 @@ 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.'),
+ message: s__('SelfMonitoring|Self-monitoring project successfully deleted.'),
actionText: __('Undo'),
actionName: 'createProject',
});
diff --git a/app/assets/javascripts/sentry/constants.js b/app/assets/javascripts/sentry/constants.js
new file mode 100644
index 00000000000..fd96da5faf6
--- /dev/null
+++ b/app/assets/javascripts/sentry/constants.js
@@ -0,0 +1,43 @@
+import { __ } from '~/locale';
+
+export const IGNORE_ERRORS = [
+ // Random plugins/extensions
+ 'top.GLOBALS',
+ // See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error. html
+ 'originalCreateNotification',
+ 'canvas.contentDocument',
+ 'MyApp_RemoveAllHighlights',
+ 'http://tt.epicplay.com',
+ __("Can't find variable: ZiteReader"),
+ __('jigsaw is not defined'),
+ __('ComboSearch is not defined'),
+ 'http://loading.retry.widdit.com/',
+ 'atomicFindClose',
+ // Facebook borked
+ 'fb_xd_fragment',
+ // ISP "optimizing" proxy - `Cache-Control: no-transform` seems to
+ // reduce this. (thanks @acdha)
+ 'bmi_SafeAddOnload',
+ 'EBCallBackMessageReceived',
+ // See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
+ 'conduitPage',
+];
+
+export const DENY_URLS = [
+ // Facebook flakiness
+ /graph\.facebook\.com/i,
+ // Facebook blocked
+ /connect\.facebook\.net\/en_US\/all\.js/i,
+ // Woopra flakiness
+ /eatdifferent\.com\.woopra-ns\.com/i,
+ /static\.woopra\.com\/js\/woopra\.js/i,
+ // Chrome extensions
+ /extensions\//i,
+ /^chrome:\/\//i,
+ // Other plugins
+ /127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb
+ /webappstoolbarba\.texthelp\.com\//i,
+ /metrics\.itunes\.apple\.com\.edgesuite\.net\//i,
+];
+
+export const SAMPLE_RATE = 0.95;
diff --git a/app/assets/javascripts/sentry/sentry_config.js b/app/assets/javascripts/sentry/sentry_config.js
index 8f3c4c644bf..4c5b8dbad5a 100644
--- a/app/assets/javascripts/sentry/sentry_config.js
+++ b/app/assets/javascripts/sentry/sentry_config.js
@@ -1,52 +1,11 @@
import * as Sentry from '@sentry/browser';
import $ from 'jquery';
import { __ } from '~/locale';
-
-const IGNORE_ERRORS = [
- // Random plugins/extensions
- 'top.GLOBALS',
- // See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error. html
- 'originalCreateNotification',
- 'canvas.contentDocument',
- 'MyApp_RemoveAllHighlights',
- 'http://tt.epicplay.com',
- __("Can't find variable: ZiteReader"),
- __('jigsaw is not defined'),
- __('ComboSearch is not defined'),
- 'http://loading.retry.widdit.com/',
- 'atomicFindClose',
- // Facebook borked
- 'fb_xd_fragment',
- // ISP "optimizing" proxy - `Cache-Control: no-transform` seems to
- // reduce this. (thanks @acdha)
- 'bmi_SafeAddOnload',
- 'EBCallBackMessageReceived',
- // See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
- 'conduitPage',
-];
-
-const BLACKLIST_URLS = [
- // Facebook flakiness
- /graph\.facebook\.com/i,
- // Facebook blocked
- /connect\.facebook\.net\/en_US\/all\.js/i,
- // Woopra flakiness
- /eatdifferent\.com\.woopra-ns\.com/i,
- /static\.woopra\.com\/js\/woopra\.js/i,
- // Chrome extensions
- /extensions\//i,
- /^chrome:\/\//i,
- // Other plugins
- /127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb
- /webappstoolbarba\.texthelp\.com\//i,
- /metrics\.itunes\.apple\.com\.edgesuite\.net\//i,
-];
-
-const SAMPLE_RATE = 0.95;
+import { IGNORE_ERRORS, DENY_URLS, SAMPLE_RATE } from './constants';
const SentryConfig = {
IGNORE_ERRORS,
- BLACKLIST_URLS,
+ BLACKLIST_URLS: DENY_URLS,
SAMPLE_RATE,
init(options = {}) {
this.options = options;
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
index 6e18cf36690..2a9100f0cb5 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
@@ -55,6 +55,7 @@ export default {
class="js-sidebar-dropdown-toggle edit-link btn gl-text-gray-900! gl-ml-auto hide-collapsed btn-default btn-sm gl-button btn-default-tertiary float-right"
href="#"
data-test-id="edit-link"
+ data-qa-selector="edit_link"
data-track-action="click_edit_button"
data-track-label="right_sidebar"
data-track-property="assignee"
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
index 29ea390a81d..cf07752a0b8 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
@@ -56,6 +56,7 @@ export default {
type="button"
class="gl-button btn-link gl-reset-color!"
data-testid="assign-yourself"
+ data-qa-selector="assign_yourself_button"
@click="assignSelf"
>
{{ __('assign yourself') }}
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 0e4d4c74160..d83ae782e26 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -91,6 +91,7 @@ export default {
<div
class="gl-ml-3 gl-line-height-normal gl-display-grid gl-align-items-center"
data-testid="username"
+ data-qa-selector="username"
>
<user-name-with-status :name="user.name" :availability="userAvailability(user)" />
</div>
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 98468583992..c262d65f6ce 100644
--- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
+++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
@@ -170,7 +170,7 @@ export default {
this.$emit('closeForm');
},
openDatePicker() {
- this.$refs.datePicker.calendar.show();
+ this.$refs.datePicker.show();
},
setFixedDate(isFixed) {
const date = this.issuable[dateFields[this.dateType].dateFixed];
diff --git a/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue b/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue
new file mode 100644
index 00000000000..1fff089eab4
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue
@@ -0,0 +1,115 @@
+<script>
+import { GlDropdownItem } from '@gitlab/ui';
+import { TYPE_MILESTONE } from '~/graphql_shared/constants';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { IssuableType, WorkspaceType } from '~/issues/constants';
+import { __ } from '~/locale';
+import { IssuableAttributeType } from '../../constants';
+import SidebarDropdown from '../sidebar_dropdown.vue';
+
+const noMilestone = {
+ id: 0,
+ title: __('No milestone'),
+};
+
+const placeholderMilestone = {
+ id: -1,
+ title: __('Select milestone'),
+};
+
+export default {
+ issuableAttribute: IssuableAttributeType.Milestone,
+ components: {
+ GlDropdownItem,
+ SidebarDropdown,
+ },
+ props: {
+ attrWorkspacePath: {
+ required: true,
+ type: String,
+ },
+ canAdminMilestone: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ issuableType: {
+ type: String,
+ required: true,
+ validator(value) {
+ return [IssuableType.Issue, IssuableType.MergeRequest].includes(value);
+ },
+ },
+ inputName: {
+ type: String,
+ required: false,
+ default: 'update[milestone_id]',
+ },
+ milestoneId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ milestoneTitle: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ projectMilestonesPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ workspaceType: {
+ type: String,
+ required: true,
+ validator(value) {
+ return [WorkspaceType.group, WorkspaceType.project].includes(value);
+ },
+ },
+ },
+ data() {
+ return {
+ milestone: this.milestoneId
+ ? { id: convertToGraphQLId(TYPE_MILESTONE, this.milestoneId), title: this.milestoneTitle }
+ : placeholderMilestone,
+ };
+ },
+ computed: {
+ footerItemText() {
+ return this.canAdminMilestone ? __('Manage milestones') : __('View milestones');
+ },
+ value() {
+ return this.milestone.id === placeholderMilestone.id
+ ? undefined
+ : getIdFromGraphQLId(this.milestone.id);
+ },
+ },
+ methods: {
+ handleChange(milestone) {
+ this.milestone = milestone.id === null ? noMilestone : milestone;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <input type="hidden" :name="inputName" :value="value" />
+ <sidebar-dropdown
+ :attr-workspace-path="attrWorkspacePath"
+ :current-attribute="milestone"
+ :issuable-attribute="$options.issuableAttribute"
+ :issuable-type="issuableType"
+ :workspace-type="workspaceType"
+ data-qa-selector="issuable_milestone_dropdown"
+ @change="handleChange"
+ >
+ <template #footer>
+ <gl-dropdown-item v-if="projectMilestonesPath" :href="projectMilestonesPath">
+ {{ footerItemText }}
+ </gl-dropdown-item>
+ </template>
+ </sidebar-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
index ad061dd2e6b..5f1350690eb 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
@@ -9,6 +9,8 @@ import eventHub from '~/sidebar/event_hub';
import Store from '~/sidebar/stores/sidebar_store';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getMergeRequestReviewersQuery from '~/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql';
+import mergeRequestReviewersUpdatedSubscription from '~/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import ReviewerTitle from './reviewer_title.vue';
import Reviewers from './reviewers.vue';
@@ -66,6 +68,36 @@ export default {
error() {
createAlert({ message: __('An error occurred while fetching reviewers.') });
},
+ subscribeToMore: {
+ document() {
+ return mergeRequestReviewersUpdatedSubscription;
+ },
+ variables() {
+ return {
+ issuableId: this.issuable?.id,
+ };
+ },
+ skip() {
+ return !this.issuable?.id || !this.isRealtimeEnabled;
+ },
+ updateQuery(
+ _,
+ {
+ subscriptionData: {
+ data: { mergeRequestReviewersUpdated },
+ },
+ },
+ ) {
+ if (mergeRequestReviewersUpdated) {
+ this.store.setReviewersFromRealtime(
+ mergeRequestReviewersUpdated.reviewers.nodes.map((r) => ({
+ ...r,
+ id: getIdFromGraphQLId(r.id),
+ })),
+ );
+ }
+ },
+ },
},
},
data() {
@@ -87,6 +119,9 @@ export default {
canUpdate() {
return this.issuable.userPermissions?.adminMergeRequest || false;
},
+ isRealtimeEnabled() {
+ return this.glFeatures.realtimeReviewers;
+ },
},
created() {
this.store = new Store();
diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers_inputs.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers_inputs.vue
new file mode 100644
index 00000000000..a135dfdca72
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers_inputs.vue
@@ -0,0 +1,34 @@
+<script>
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { state } from './sidebar_reviewers.vue';
+
+export default {
+ data() {
+ return state;
+ },
+ computed: {
+ reviewers() {
+ return this.issuable?.reviewers?.nodes || [];
+ },
+ },
+ methods: {
+ getIdFromGraphQLId,
+ },
+};
+</script>
+
+<template>
+ <div>
+ <input
+ v-for="reviewer in reviewers"
+ :key="reviewer.id"
+ type="hidden"
+ name="merge_request[reviewer_ids][]"
+ :value="getIdFromGraphQLId(reviewer.id)"
+ :data-avatar-url="reviewer.avatarUrl"
+ :data-name="reviewer.name"
+ :data-username="reviewer.username"
+ :data-can-merge="reviewer.mergeRequestInteraction.canMerge"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue
new file mode 100644
index 00000000000..26e2bc96f54
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue
@@ -0,0 +1,252 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlDropdownText,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import { kebabCase, snakeCase } from 'lodash';
+import { IssuableType, WorkspaceType } from '~/issues/constants';
+import { __ } from '~/locale';
+import {
+ defaultEpicSort,
+ dropdowni18nText,
+ epicIidPattern,
+ issuableAttributesQueries,
+ IssuableAttributeState,
+ IssuableAttributeType,
+ IssuableAttributeTypeKeyMap,
+ LocalizedIssuableAttributeType,
+ noAttributeId,
+} from 'ee_else_ce/sidebar/constants';
+import { createAlert } from '~/flash';
+import { PathIdSeparator } from '~/related_issues/constants';
+
+export default {
+ noAttributeId,
+ i18n: {
+ expired: __('(expired)'),
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+ },
+ inject: {
+ issuableAttributesQueries: {
+ default: issuableAttributesQueries,
+ },
+ issuableAttributesState: {
+ default: IssuableAttributeState,
+ },
+ widgetTitleText: {
+ default: {
+ [IssuableAttributeType.Milestone]: __('Milestone'),
+ expired: __('(expired)'),
+ none: __('None'),
+ },
+ },
+ },
+ props: {
+ attrWorkspacePath: {
+ required: true,
+ type: String,
+ },
+ currentAttribute: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ issuableAttribute: {
+ type: String,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: true,
+ validator(value) {
+ return [IssuableType.Issue, IssuableType.MergeRequest].includes(value);
+ },
+ },
+ workspaceType: {
+ type: String,
+ required: false,
+ default: WorkspaceType.project,
+ validator(value) {
+ return [WorkspaceType.group, WorkspaceType.project].includes(value);
+ },
+ },
+ },
+ data() {
+ return {
+ attributesList: [],
+ searchTerm: '',
+ skipQuery: true,
+ };
+ },
+ apollo: {
+ attributesList: {
+ query() {
+ const { list } = this.issuableAttributeQuery;
+ const { query } = list[this.issuableType];
+ return query[this.workspaceType] || query;
+ },
+ variables() {
+ if (!this.isEpic) {
+ return {
+ fullPath: this.attrWorkspacePath,
+ title: this.searchTerm,
+ state: this.issuableAttributesState[this.issuableAttribute],
+ };
+ }
+
+ const variables = {
+ fullPath: this.attrWorkspacePath,
+ state: this.issuableAttributesState[this.issuableAttribute],
+ sort: defaultEpicSort,
+ };
+
+ if (epicIidPattern.test(this.searchTerm)) {
+ const matches = this.searchTerm.match(epicIidPattern);
+ variables.iidStartsWith = matches.groups.iid;
+ } else if (this.searchTerm !== '') {
+ variables.in = 'TITLE';
+ variables.title = this.searchTerm;
+ }
+
+ return variables;
+ },
+ update: (data) => data?.workspace?.attributes?.nodes ?? [],
+ error(error) {
+ createAlert({ message: this.i18n.listFetchError, captureError: true, error });
+ },
+ skip() {
+ if (
+ this.isEpic &&
+ this.searchTerm.startsWith(PathIdSeparator.Epic) &&
+ this.searchTerm.length < 2
+ ) {
+ return true;
+ }
+ return this.skipQuery;
+ },
+ debounce: 250,
+ },
+ },
+ computed: {
+ attributeTypeTitle() {
+ return this.widgetTitleText[this.issuableAttribute];
+ },
+ dropdownText() {
+ return this.currentAttribute ? this.currentAttribute?.title : this.attributeTypeTitle;
+ },
+ emptyPropsList() {
+ return this.attributesList.length === 0;
+ },
+ i18n() {
+ const localizedAttribute =
+ LocalizedIssuableAttributeType[IssuableAttributeTypeKeyMap[this.issuableAttribute]];
+ return dropdowni18nText(localizedAttribute, this.issuableType);
+ },
+ isEpic() {
+ // MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311
+ return this.issuableAttribute === IssuableType.Epic;
+ },
+ issuableAttributeQuery() {
+ return this.issuableAttributesQueries[this.issuableAttribute];
+ },
+ formatIssuableAttribute() {
+ return {
+ kebab: kebabCase(this.issuableAttribute),
+ snake: snakeCase(this.issuableAttribute),
+ };
+ },
+ },
+ methods: {
+ isAttributeChecked(attributeId) {
+ return (
+ attributeId === this.currentAttribute?.id || (!this.currentAttribute?.id && !attributeId)
+ );
+ },
+ isAttributeOverdue(attribute) {
+ return this.issuableAttribute === IssuableAttributeType.Milestone
+ ? attribute?.expired
+ : false;
+ },
+ handleShow() {
+ this.skipQuery = false;
+ },
+ setFocus() {
+ this.$refs.search.focusInput();
+ },
+ show() {
+ this.$refs.dropdown.show();
+ },
+ updateAttribute(attribute) {
+ this.$emit('change', attribute);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ ref="dropdown"
+ block
+ :header-text="i18n.assignAttribute"
+ lazy
+ :text="dropdownText"
+ toggle-class="gl-m-0"
+ @show="handleShow"
+ @shown="setFocus"
+ >
+ <gl-search-box-by-type ref="search" v-model="searchTerm" :placeholder="__('Search')" />
+ <gl-dropdown-item
+ :data-testid="`no-${formatIssuableAttribute.kebab}-item`"
+ is-check-item
+ :is-checked="isAttributeChecked($options.noAttributeId)"
+ @click="$emit('change', { id: $options.noAttributeId })"
+ >
+ {{ i18n.noAttribute }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-loading-icon
+ v-if="$apollo.queries.attributesList.loading"
+ size="sm"
+ class="gl-py-4"
+ data-testid="loading-icon-dropdown"
+ />
+ <template v-else>
+ <gl-dropdown-text v-if="emptyPropsList">
+ {{ i18n.noAttributesFound }}
+ </gl-dropdown-text>
+ <slot
+ v-else
+ name="list"
+ :attributes-list="attributesList"
+ :is-attribute-checked="isAttributeChecked"
+ :update-attribute="updateAttribute"
+ >
+ <gl-dropdown-item
+ v-for="attrItem in attributesList"
+ :key="attrItem.id"
+ is-check-item
+ :is-checked="isAttributeChecked(attrItem.id)"
+ :data-testid="`${formatIssuableAttribute.kebab}-items`"
+ @click="updateAttribute(attrItem)"
+ >
+ {{ attrItem.title }}
+ <template v-if="isAttributeOverdue(attrItem)">{{ $options.i18n.expired }}</template>
+ </gl-dropdown-item>
+ </slot>
+ </template>
+ <template #footer>
+ <slot name="footer"></slot>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
index c33b1468ca4..a685929cdea 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -1,17 +1,5 @@
<script>
-import {
- GlLink,
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlSearchBoxByType,
- GlDropdownDivider,
- GlLoadingIcon,
- GlIcon,
- GlTooltipDirective,
- GlPopover,
- GlButton,
-} from '@gitlab/ui';
+import { GlButton, GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab/ui';
import { kebabCase, snakeCase } from 'lodash';
import { createAlert } from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -22,19 +10,15 @@ import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
dropdowni18nText,
- Tracking,
- IssuableAttributeState,
- IssuableAttributeType,
LocalizedIssuableAttributeType,
IssuableAttributeTypeKeyMap,
issuableAttributesQueries,
- noAttributeId,
- defaultEpicSort,
- epicIidPattern,
+ IssuableAttributeType,
+ Tracking,
} from 'ee_else_ce/sidebar/constants';
+import SidebarDropdown from './sidebar_dropdown.vue';
export default {
- noAttributeId,
i18n: {
expired: __('(expired)'),
none: __('None'),
@@ -43,17 +27,12 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: {
- SidebarEditableItem,
GlLink,
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlDropdownDivider,
- GlSearchBoxByType,
GlIcon,
- GlLoadingIcon,
GlPopover,
GlButton,
+ SidebarDropdown,
+ SidebarEditableItem,
},
mixins: [glFeatureFlagMixin()],
inject: {
@@ -63,9 +42,6 @@ export default {
issuableAttributesQueries: {
default: issuableAttributesQueries,
},
- issuableAttributesState: {
- default: IssuableAttributeState,
- },
widgetTitleText: {
default: {
[IssuableAttributeType.Milestone]: __('Milestone'),
@@ -74,7 +50,6 @@ export default {
},
},
},
-
props: {
issuableAttribute: {
type: String,
@@ -134,67 +109,14 @@ export default {
});
},
},
- attributesList: {
- query() {
- const { list } = this.issuableAttributeQuery;
- const { query } = list[this.issuableType];
-
- return query;
- },
- skip() {
- if (this.isEpic && this.searchTerm.startsWith('&') && this.searchTerm.length < 2) {
- return true;
- }
-
- return !this.editing;
- },
- debounce: 250,
- variables() {
- if (!this.isEpic) {
- return {
- fullPath: this.attrWorkspacePath,
- title: this.searchTerm,
- state: this.issuableAttributesState[this.issuableAttribute],
- };
- }
-
- const variables = {
- fullPath: this.attrWorkspacePath,
- state: this.issuableAttributesState[this.issuableAttribute],
- sort: defaultEpicSort,
- };
-
- if (epicIidPattern.test(this.searchTerm)) {
- const matches = this.searchTerm.match(epicIidPattern);
- variables.iidStartsWith = matches.groups.iid;
- } else if (this.searchTerm !== '') {
- variables.in = 'TITLE';
- variables.title = this.searchTerm;
- }
-
- return variables;
- },
- update(data) {
- if (data?.workspace) {
- return data?.workspace?.attributes.nodes;
- }
- return [];
- },
- error(error) {
- createAlert({ message: this.i18n.listFetchError, captureError: true, error });
- },
- },
},
data() {
return {
- searchTerm: '',
- editing: false,
updating: false,
selectedTitle: null,
currentAttribute: null,
hasCurrentAttribute: false,
editConfirmation: false,
- attributesList: [],
tracking: {
event: Tracking.editEvent,
label: Tracking.rightSidebarLabel,
@@ -212,15 +134,9 @@ export default {
attributeUrl() {
return this.currentAttribute?.webUrl;
},
- dropdownText() {
- return this.currentAttribute ? this.currentAttribute?.title : this.attributeTypeTitle;
- },
loading() {
return this.$apollo.queries.currentAttribute.loading;
},
- emptyPropsList() {
- return this.attributesList.length === 0;
- },
attributeTypeTitle() {
return this.widgetTitleText[this.issuableAttribute];
},
@@ -256,16 +172,12 @@ export default {
},
},
methods: {
- updateAttribute(attributeId) {
- if (this.currentAttribute === null && attributeId === null) return;
- if (attributeId === this.currentAttribute?.id) return;
+ updateAttribute({ id }) {
+ if (this.currentAttribute === null && id === null) return;
+ if (id === this.currentAttribute?.id) return;
this.updating = true;
- const selectedAttribute =
- Boolean(attributeId) && this.attributesList.find((p) => p.id === attributeId);
- this.selectedTitle = selectedAttribute ? selectedAttribute.title : this.widgetTitleText.none;
-
const { current } = this.issuableAttributeQuery;
const { mutation } = current[this.issuableType];
@@ -277,8 +189,8 @@ export default {
attributeId:
this.issuableAttribute === IssuableAttributeType.Milestone &&
this.issuableType === IssuableType.Issue
- ? getIdFromGraphQLId(attributeId)
- : attributeId,
+ ? getIdFromGraphQLId(id)
+ : id,
iid: this.iid,
},
})
@@ -298,32 +210,16 @@ export default {
})
.finally(() => {
this.updating = false;
- this.searchTerm = '';
this.selectedTitle = null;
});
},
- isAttributeChecked(attributeId = undefined) {
- return (
- attributeId === this.currentAttribute?.id || (!this.currentAttribute?.id && !attributeId)
- );
- },
isAttributeOverdue(attribute) {
return this.issuableAttribute === IssuableAttributeType.Milestone
? attribute?.expired
: false;
},
showDropdown() {
- this.$refs.newDropdown.show();
- },
- handleOpen() {
- this.editing = true;
- this.showDropdown();
- },
- handleClose() {
- this.editing = false;
- },
- setFocus() {
- this.$refs.search.focusInput();
+ this.$refs.dropdown.show();
},
handlePopoverClose() {
this.$refs.popover.$emit('close');
@@ -349,8 +245,7 @@ export default {
:tracking="tracking"
:should-show-confirmation-popover="shouldShowConfirmationPopover"
:loading="updating || loading"
- @open="handleOpen"
- @close="handleClose"
+ @open="showDropdown"
@edit-confirm="handleEditConfirmation"
>
<template #collapsed>
@@ -432,58 +327,24 @@ export default {
</gl-popover>
</template>
<template v-else #default>
- <gl-dropdown
- ref="newDropdown"
- lazy
- :header-text="i18n.assignAttribute"
- :text="dropdownText"
- :loading="loading"
- class="gl-w-full"
- toggle-class="gl-max-w-100"
- block
- @shown="setFocus"
+ <sidebar-dropdown
+ ref="dropdown"
+ :attr-workspace-path="attrWorkspacePath"
+ :current-attribute="currentAttribute"
+ :issuable-attribute="issuableAttribute"
+ :issuable-type="issuableType"
+ @change="updateAttribute"
>
- <gl-search-box-by-type ref="search" v-model="searchTerm" />
- <gl-dropdown-item
- :data-testid="`no-${formatIssuableAttribute.kebab}-item`"
- is-check-item
- :is-checked="isAttributeChecked($options.noAttributeId)"
- @click="updateAttribute($options.noAttributeId)"
- >
- {{ i18n.noAttribute }}
- </gl-dropdown-item>
- <gl-dropdown-divider />
- <gl-loading-icon
- v-if="$apollo.queries.attributesList.loading"
- size="sm"
- class="gl-py-4"
- data-testid="loading-icon-dropdown"
- />
- <template v-else>
- <gl-dropdown-text v-if="emptyPropsList">
- {{ i18n.noAttributesFound }}
- </gl-dropdown-text>
+ <template #list="{ attributesList, isAttributeChecked, updateAttribute: update }">
<slot
- v-else
name="list"
:attributes-list="attributesList"
:is-attribute-checked="isAttributeChecked"
- :update-attribute="updateAttribute"
+ :update-attribute="update"
>
- <gl-dropdown-item
- v-for="attrItem in attributesList"
- :key="attrItem.id"
- is-check-item
- :is-checked="isAttributeChecked(attrItem.id)"
- :data-testid="`${formatIssuableAttribute.kebab}-items`"
- @click="updateAttribute(attrItem.id)"
- >
- {{ attrItem.title }}
- <span v-if="isAttributeOverdue(attrItem)">{{ $options.i18n.expired }}</span>
- </gl-dropdown-item>
</slot>
</template>
- </gl-dropdown>
+ </sidebar-dropdown>
</template>
</sidebar-editable-item>
</template>
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index 6248bcb8e2d..67b9b540e91 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -53,6 +53,7 @@ import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/querie
import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
import getEscalationStatusQuery from '~/sidebar/queries/escalation_status.query.graphql';
import updateEscalationStatusMutation from '~/sidebar/queries/update_escalation_status.mutation.graphql';
+import groupMilestonesQuery from './queries/group_milestones.query.graphql';
import projectIssueMilestoneMutation from './queries/project_issue_milestone.mutation.graphql';
import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql';
import projectMilestonesQuery from './queries/project_milestones.query.graphql';
@@ -241,10 +242,16 @@ export const issuableMilestoneQueries = {
export const milestonesQueries = {
[IssuableType.Issue]: {
- query: projectMilestonesQuery,
+ query: {
+ [WorkspaceType.group]: groupMilestonesQuery,
+ [WorkspaceType.project]: projectMilestonesQuery,
+ },
},
[IssuableType.MergeRequest]: {
- query: projectMilestonesQuery,
+ query: {
+ [WorkspaceType.group]: groupMilestonesQuery,
+ [WorkspaceType.project]: projectMilestonesQuery,
+ },
},
};
diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
index cc5de5e4083..afce59d304f 100644
--- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
@@ -5,7 +5,7 @@ import TimeTracker from './components/time_tracking/time_tracker.vue';
export default class SidebarMilestone {
constructor() {
- const el = document.getElementById('issuable-time-tracker');
+ const el = document.querySelector('.js-sidebar-time-tracking-root');
if (!el) return;
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 9b5bad710dd..b37486283ca 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -18,6 +18,7 @@ import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assi
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import SidebarDueDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
+import MilestoneDropdown from '~/sidebar/components/milestone/milestone_dropdown.vue';
import SidebarParticipantsWidget from '~/sidebar/components/participants/sidebar_participants_widget.vue';
import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue';
import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue';
@@ -33,6 +34,7 @@ import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue';
import SidebarEscalationStatus from './components/incidents/sidebar_escalation_status.vue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue';
import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue';
+import SidebarReviewersInputs from './components/reviewers/sidebar_reviewers_inputs.vue';
import SidebarSeverity from './components/severity/sidebar_severity.vue';
import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subscriptions_widget.vue';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
@@ -47,27 +49,24 @@ function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-op
return JSON.parse(sidebarOptEl.innerHTML);
}
-function mountSidebarToDoWidget() {
- const el = document.querySelector('.js-issuable-todo');
+function mountSidebarTodoWidget() {
+ const el = document.querySelector('.js-sidebar-todo-widget-root');
if (!el) {
- return false;
+ return null;
}
const { projectPath, iid, id } = el.dataset;
return new Vue({
el,
- name: 'SidebarTodoRoot',
+ name: 'SidebarTodoWidgetRoot',
apolloProvider,
- components: {
- SidebarTodoWidget,
- },
provide: {
isClassicSidebar: true,
},
render: (createElement) =>
- createElement('sidebar-todo-widget', {
+ createElement(SidebarTodoWidget, {
props: {
fullPath: projectPath,
issuableId:
@@ -97,23 +96,22 @@ function getSidebarAssigneeAvailabilityData() {
);
}
-function mountAssigneesComponentDeprecated(mediator) {
- const el = document.getElementById('js-vue-sidebar-assignees');
+function mountSidebarAssigneesDeprecated(mediator) {
+ const el = document.querySelector('.js-sidebar-assignees-root');
- if (!el) return;
+ if (!el) {
+ return null;
+ }
const { id, iid, fullPath } = getSidebarOptions();
const assigneeAvailabilityStatus = getSidebarAssigneeAvailabilityData();
- // eslint-disable-next-line no-new
- new Vue({
+
+ return new Vue({
el,
name: 'SidebarAssigneesRoot',
apolloProvider,
- components: {
- SidebarAssignees,
- },
render: (createElement) =>
- createElement('sidebar-assignees', {
+ createElement(SidebarAssignees, {
props: {
mediator,
issuableIid: String(iid),
@@ -131,10 +129,12 @@ function mountAssigneesComponentDeprecated(mediator) {
});
}
-function mountAssigneesComponent() {
- const el = document.getElementById('js-vue-sidebar-assignees');
+function mountSidebarAssigneesWidget() {
+ const el = document.querySelector('.js-sidebar-assignees-root');
- if (!el) return;
+ if (!el) {
+ return;
+ }
const { id, iid, fullPath, editable } = getSidebarOptions();
const isIssuablePage = isInIssuePage() || isInIncidentPage() || isInDesignPage();
@@ -144,9 +144,6 @@ function mountAssigneesComponent() {
el,
name: 'SidebarAssigneesRoot',
apolloProvider,
- components: {
- SidebarAssigneesWidget,
- },
provide: {
canUpdate: editable,
directlyInviteMembers: Object.prototype.hasOwnProperty.call(
@@ -155,7 +152,7 @@ function mountAssigneesComponent() {
),
},
render: (createElement) =>
- createElement('sidebar-assignees-widget', {
+ createElement(SidebarAssigneesWidget, {
props: {
iid: String(iid),
fullPath,
@@ -183,10 +180,12 @@ function mountAssigneesComponent() {
}
}
-function mountReviewersComponent(mediator) {
- const el = document.getElementById('js-vue-sidebar-reviewers');
+function mountSidebarReviewers(mediator) {
+ const el = document.querySelector('.js-sidebar-reviewers-root');
- if (!el) return;
+ if (!el) {
+ return;
+ }
const { iid, fullPath } = getSidebarOptions();
// eslint-disable-next-line no-new
@@ -194,11 +193,8 @@ function mountReviewersComponent(mediator) {
el,
name: 'SidebarReviewersRoot',
apolloProvider,
- components: {
- SidebarReviewers,
- },
render: (createElement) =>
- createElement('sidebar-reviewers', {
+ createElement(SidebarReviewers, {
props: {
mediator,
issuableIid: String(iid),
@@ -210,6 +206,18 @@ function mountReviewersComponent(mediator) {
}),
});
+ const reviewersInputEl = document.querySelector('.js-reviewers-inputs');
+
+ if (reviewersInputEl) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: reviewersInputEl,
+ render(createElement) {
+ return createElement(SidebarReviewersInputs);
+ },
+ });
+ }
+
const reviewerDropdown = document.querySelector('.js-sidebar-reviewer-dropdown');
if (reviewerDropdown) {
@@ -217,22 +225,21 @@ function mountReviewersComponent(mediator) {
}
}
-function mountCrmContactsComponent() {
- const el = document.getElementById('js-issue-crm-contacts');
+function mountSidebarCrmContacts() {
+ const el = document.querySelector('.js-sidebar-crm-contacts-root');
- if (!el) return;
+ if (!el) {
+ return null;
+ }
const { issueId, groupIssuesPath } = el.dataset;
- // eslint-disable-next-line no-new
- new Vue({
+
+ return new Vue({
el,
name: 'SidebarCrmContactsRoot',
apolloProvider,
- components: {
- CrmContacts,
- },
render: (createElement) =>
- createElement('crm-contacts', {
+ createElement(CrmContacts, {
props: {
issueId,
groupIssuesPath,
@@ -241,28 +248,25 @@ function mountCrmContactsComponent() {
});
}
-function mountMilestoneSelect() {
- const el = document.querySelector('.js-milestone-select');
+function mountSidebarMilestoneWidget() {
+ const el = document.querySelector('.js-sidebar-milestone-widget-root');
if (!el) {
- return false;
+ return null;
}
const { canEdit, projectPath, issueIid } = el.dataset;
return new Vue({
el,
- name: 'SidebarMilestoneRoot',
+ name: 'SidebarMilestoneWidgetRoot',
apolloProvider,
- components: {
- SidebarDropdownWidget,
- },
provide: {
canUpdate: parseBoolean(canEdit),
isClassicSidebar: true,
},
render: (createElement) =>
- createElement('sidebar-dropdown-widget', {
+ createElement(SidebarDropdownWidget, {
props: {
attrWorkspacePath: projectPath,
workspacePath: projectPath,
@@ -276,21 +280,57 @@ function mountMilestoneSelect() {
});
}
-export function mountSidebarLabels() {
- const el = document.querySelector('.js-sidebar-labels');
+export function mountMilestoneDropdown() {
+ const el = document.querySelector('.js-milestone-dropdown-root');
if (!el) {
- return false;
+ return null;
}
+ Vue.use(VueApollo);
+
+ const {
+ canAdminMilestone,
+ fullPath,
+ inputName,
+ milestoneId,
+ milestoneTitle,
+ projectMilestonesPath,
+ workspaceType,
+ } = el.dataset;
+
return new Vue({
el,
- name: 'SidebarLabelsRoot',
+ name: 'MilestoneDropdownRoot',
apolloProvider,
-
- components: {
- LabelsSelectWidget,
+ render(createElement) {
+ return createElement(MilestoneDropdown, {
+ props: {
+ attrWorkspacePath: fullPath,
+ canAdminMilestone,
+ inputName,
+ issuableType: isInIssuePage() ? IssuableType.Issue : IssuableType.MergeRequest,
+ milestoneId,
+ milestoneTitle,
+ projectMilestonesPath,
+ workspaceType,
+ },
+ });
},
+ });
+}
+
+export function mountSidebarLabelsWidget() {
+ const el = document.querySelector('.js-sidebar-labels-widget-root');
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
+ name: 'SidebarLabelsWidgetRoot',
+ apolloProvider,
provide: {
...el.dataset,
canUpdate: parseBoolean(el.dataset.canEdit),
@@ -300,7 +340,7 @@ export function mountSidebarLabels() {
isClassicSidebar: true,
},
render: (createElement) =>
- createElement('labels-select-widget', {
+ createElement(LabelsSelectWidget, {
props: {
iid: String(el.dataset.iid),
fullPath: el.dataset.projectPath,
@@ -327,31 +367,27 @@ export function mountSidebarLabels() {
});
}
-function mountConfidentialComponent() {
- const el = document.getElementById('js-confidential-entry-point');
+function mountSidebarConfidentialityWidget() {
+ const el = document.querySelector('.js-sidebar-confidential-widget-root');
+
if (!el) {
- return;
+ return null;
}
const { fullPath, iid } = getSidebarOptions();
const dataNode = document.getElementById('js-confidential-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
- // eslint-disable-next-line no-new
- new Vue({
+ return new Vue({
el,
- name: 'SidebarConfidentialRoot',
+ name: 'SidebarConfidentialityWidgetRoot',
apolloProvider,
- components: {
- SidebarConfidentialityWidget,
- },
provide: {
canUpdate: initialData.is_editable,
isClassicSidebar: true,
},
-
render: (createElement) =>
- createElement('sidebar-confidentiality-widget', {
+ createElement(SidebarConfidentialityWidget, {
props: {
iid: String(iid),
fullPath,
@@ -364,28 +400,24 @@ function mountConfidentialComponent() {
});
}
-function mountDueDateComponent() {
- const el = document.getElementById('js-due-date-entry-point');
+function mountSidebarDueDateWidget() {
+ const el = document.querySelector('.js-sidebar-due-date-widget-root');
+
if (!el) {
- return;
+ return null;
}
const { fullPath, iid, editable } = getSidebarOptions();
- // eslint-disable-next-line no-new
- new Vue({
+ return new Vue({
el,
- name: 'SidebarDueDateRoot',
+ name: 'SidebarDueDateWidgetRoot',
apolloProvider,
- components: {
- SidebarDueDateWidget,
- },
provide: {
canUpdate: editable,
},
-
render: (createElement) =>
- createElement('sidebar-due-date-widget', {
+ createElement(SidebarDueDateWidget, {
props: {
iid: String(iid),
fullPath,
@@ -395,29 +427,25 @@ function mountDueDateComponent() {
});
}
-function mountReferenceComponent() {
- const el = document.getElementById('js-reference-entry-point');
+function mountSidebarReferenceWidget() {
+ const el = document.querySelector('.js-sidebar-reference-widget-root');
+
if (!el) {
- return;
+ return null;
}
const { fullPath, iid } = getSidebarOptions();
- // eslint-disable-next-line no-new
- new Vue({
+ return new Vue({
el,
- name: 'SidebarReferenceRoot',
+ name: 'SidebarReferenceWidgetRoot',
apolloProvider,
- components: {
- SidebarReferenceWidget,
- },
provide: {
iid: String(iid),
fullPath,
},
-
render: (createElement) =>
- createElement('sidebar-reference-widget', {
+ createElement(SidebarReferenceWidget, {
props: {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
@@ -428,17 +456,16 @@ function mountReferenceComponent() {
});
}
-function mountLockComponent(store) {
- const el = document.getElementById('js-lock-entry-point');
+function mountIssuableLockForm(store) {
+ const el = document.querySelector('.js-sidebar-lock-root');
if (!el || !store) {
- return;
+ return null;
}
const { fullPath, editable } = getSidebarOptions();
- // eslint-disable-next-line no-new
- new Vue({
+ return new Vue({
el,
name: 'SidebarLockRoot',
store,
@@ -454,23 +481,21 @@ function mountLockComponent(store) {
});
}
-function mountParticipantsComponent() {
- const el = document.querySelector('.js-sidebar-participants-entry-point');
+function mountSidebarParticipantsWidget() {
+ const el = document.querySelector('.js-sidebar-participants-widget-root');
- if (!el) return;
+ if (!el) {
+ return null;
+ }
const { fullPath, iid } = getSidebarOptions();
- // eslint-disable-next-line no-new
- new Vue({
+ return new Vue({
el,
- name: 'SidebarParticipantsRoot',
+ name: 'SidebarParticipantsWidgetRoot',
apolloProvider,
- components: {
- SidebarParticipantsWidget,
- },
render: (createElement) =>
- createElement('sidebar-participants-widget', {
+ createElement(SidebarParticipantsWidget, {
props: {
iid: String(iid),
fullPath,
@@ -483,26 +508,24 @@ function mountParticipantsComponent() {
});
}
-function mountSubscriptionsComponent() {
- const el = document.querySelector('.js-sidebar-subscriptions-entry-point');
+function mountSidebarSubscriptionsWidget() {
+ const el = document.querySelector('.js-sidebar-subscriptions-widget-root');
- if (!el) return;
+ if (!el) {
+ return null;
+ }
const { fullPath, iid, editable } = getSidebarOptions();
- // eslint-disable-next-line no-new
- new Vue({
+ return new Vue({
el,
- name: 'SidebarSubscriptionsRoot',
+ name: 'SidebarSubscriptionsWidgetRoot',
apolloProvider,
- components: {
- SidebarSubscriptionsWidget,
- },
provide: {
canUpdate: editable,
},
render: (createElement) =>
- createElement('sidebar-subscriptions-widget', {
+ createElement(SidebarSubscriptionsWidget, {
props: {
iid: String(iid),
fullPath,
@@ -515,14 +538,15 @@ function mountSubscriptionsComponent() {
});
}
-function mountTimeTrackingComponent() {
- const el = document.getElementById('issuable-time-tracker');
+function mountSidebarTimeTracking() {
+ const el = document.querySelector('.js-sidebar-time-tracking-root');
const { id, iid, fullPath, issuableType, timeTrackingLimitToHours } = getSidebarOptions();
- if (!el) return;
+ if (!el) {
+ return null;
+ }
- // eslint-disable-next-line no-new
- new Vue({
+ return new Vue({
el,
name: 'SidebarTimeTrackingRoot',
apolloProvider,
@@ -539,27 +563,24 @@ function mountTimeTrackingComponent() {
});
}
-function mountSeverityComponent() {
- const severityContainerEl = document.querySelector('#js-severity');
+function mountSidebarSeverity() {
+ const el = document.querySelector('.js-sidebar-severity-root');
- if (!severityContainerEl) {
- return false;
+ if (!el) {
+ return null;
}
const { fullPath, iid, severity, editable } = getSidebarOptions();
return new Vue({
- el: severityContainerEl,
+ el,
name: 'SidebarSeverityRoot',
apolloProvider,
- components: {
- SidebarSeverity,
- },
provide: {
canUpdate: editable,
},
render: (createElement) =>
- createElement('sidebar-severity', {
+ createElement(SidebarSeverity, {
props: {
projectPath: fullPath,
iid: String(iid),
@@ -569,27 +590,25 @@ function mountSeverityComponent() {
});
}
-function mountEscalationStatusComponent() {
- const statusContainerEl = document.querySelector('#js-escalation-status');
+function mountSidebarEscalationStatus() {
+ const el = document.querySelector('.js-sidebar-escalation-status-root');
- if (!statusContainerEl) {
- return false;
+ if (!el) {
+ return null;
}
const { issuableType } = getSidebarOptions();
- const { canUpdate, issueIid, projectPath } = statusContainerEl.dataset;
+ const { canUpdate, issueIid, projectPath } = el.dataset;
return new Vue({
- el: statusContainerEl,
+ el,
+ name: 'SidebarEscalationStatusRoot',
apolloProvider,
- components: {
- SidebarEscalationStatus,
- },
provide: {
canUpdate: parseBoolean(canUpdate),
},
render: (createElement) =>
- createElement('sidebar-escalation-status', {
+ createElement(SidebarEscalationStatus, {
props: {
iid: issueIid,
issuableType,
@@ -599,15 +618,16 @@ function mountEscalationStatusComponent() {
});
}
-function mountCopyEmailComponent() {
- const el = document.getElementById('issuable-copy-email');
+function mountCopyEmailToClipboard() {
+ const el = document.querySelector('.js-sidebar-copy-email-root');
- if (!el) return;
+ if (!el) {
+ return null;
+ }
const { createNoteEmail } = getSidebarOptions();
- // eslint-disable-next-line no-new
- new Vue({
+ return new Vue({
el,
name: 'SidebarCopyEmailRoot',
render: (createElement) =>
@@ -621,36 +641,32 @@ const isAssigneesWidgetShown =
export function mountSidebar(mediator, store) {
initInviteMembersModal();
initInviteMembersTrigger();
-
- mountSidebarToDoWidget();
+ mountSidebarTodoWidget();
if (isAssigneesWidgetShown) {
- mountAssigneesComponent();
+ mountSidebarAssigneesWidget();
} else {
- mountAssigneesComponentDeprecated(mediator);
+ mountSidebarAssigneesDeprecated(mediator);
}
- mountReviewersComponent(mediator);
- mountCrmContactsComponent();
- mountSidebarLabels();
- mountMilestoneSelect();
- mountConfidentialComponent(mediator);
- mountDueDateComponent(mediator);
- mountReferenceComponent(mediator);
- mountLockComponent(store);
- mountParticipantsComponent();
- mountSubscriptionsComponent();
- mountCopyEmailComponent();
+ mountSidebarReviewers(mediator);
+ mountSidebarCrmContacts();
+ mountSidebarLabelsWidget();
+ mountSidebarMilestoneWidget();
+ mountSidebarConfidentialityWidget();
+ mountSidebarDueDateWidget();
+ mountSidebarReferenceWidget();
+ mountIssuableLockForm(store);
+ mountSidebarParticipantsWidget();
+ mountSidebarSubscriptionsWidget();
+ mountCopyEmailToClipboard();
+ mountSidebarTimeTracking();
+ mountSidebarSeverity();
+ mountSidebarEscalationStatus();
new SidebarMoveIssue(
mediator,
$('.js-move-issue'),
$('.js-move-issue-confirmation-button'),
).init();
-
- mountTimeTrackingComponent();
-
- mountSeverityComponent();
-
- mountEscalationStatusComponent();
}
export { getSidebarOptions };
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index e2581a8f30e..baf906bb96c 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -138,6 +138,10 @@ export default class SidebarStore {
this.assignees = data;
}
+ setReviewersFromRealtime(data) {
+ this.reviewers = data;
+ }
+
setAutocompleteProjects(projects) {
this.autocompleteProjects = projects;
}
diff --git a/app/assets/javascripts/token_access/components/token_access.vue b/app/assets/javascripts/token_access/components/token_access.vue
index 4b91872d80d..fe99f3e1fdd 100644
--- a/app/assets/javascripts/token_access/components/token_access.vue
+++ b/app/assets/javascripts/token_access/components/token_access.vue
@@ -117,7 +117,7 @@ export default {
throw new Error(errors[0]);
}
} catch (error) {
- createAlert({ message: error });
+ createAlert({ message: error.message });
}
},
async addProject() {
@@ -140,7 +140,7 @@ export default {
throw new Error(errors[0]);
}
} catch (error) {
- createAlert({ message: error });
+ createAlert({ message: error.message });
} finally {
this.clearTargetProjectPath();
this.getProjects();
@@ -166,7 +166,7 @@ export default {
throw new Error(errors[0]);
}
} catch (error) {
- createAlert({ message: error });
+ createAlert({ message: error.message });
} finally {
this.getProjects();
}
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 82ef3371d91..ce33478cbee 100644
--- a/app/assets/javascripts/token_access/components/token_projects_table.vue
+++ b/app/assets/javascripts/token_access/components/token_projects_table.vue
@@ -10,14 +10,21 @@ export default {
{
key: 'project',
label: __('Projects that can be accessed'),
- tdClass: 'gl-p-5!',
- columnClass: 'gl-w-85p',
+ thClass: 'gl-border-t-none!',
+ columnClass: 'gl-w-40p',
+ },
+ {
+ key: 'namespace',
+ label: __('Namespace'),
+ thClass: 'gl-border-t-none!',
+ columnClass: 'gl-w-40p',
},
{
key: 'actions',
label: '',
- tdClass: 'gl-p-5! gl-text-right',
- columnClass: 'gl-w-15p',
+ tdClass: 'gl-text-right',
+ thClass: 'gl-border-t-none!',
+ columnClass: 'gl-w-10p',
},
],
components: {
@@ -57,7 +64,11 @@ export default {
</template>
<template #cell(project)="{ item }">
- {{ item.name }}
+ <span data-testid="token-access-project-name">{{ item.name }}</span>
+ </template>
+
+ <template #cell(namespace)="{ item }">
+ <span data-testid="token-access-project-namespace">{{ item.namespace.fullPath }}</span>
</template>
<template #cell(actions)="{ item }">
diff --git a/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql
index 664991bc110..a243095f1b4 100644
--- a/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql
+++ b/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql
@@ -6,6 +6,10 @@ query getProjectsWithCIJobTokenScope($fullPath: ID!) {
nodes {
id
name
+ namespace {
+ id
+ fullPath
+ }
fullPath
}
}
diff --git a/app/assets/javascripts/tracking/tracker.js b/app/assets/javascripts/tracking/tracker.js
index 9ad86e76b6e..85f4979e752 100644
--- a/app/assets/javascripts/tracking/tracker.js
+++ b/app/assets/javascripts/tracking/tracker.js
@@ -39,8 +39,8 @@ export const Tracker = {
},
/**
- * Dispatches a structured event per our taxonomy:
- * https://docs.gitlab.com/ee/development/snowplow/index.html#structured-event-taxonomy.
+ * Dispatches a structured event:
+ * https://docs.gitlab.com/ee/development/snowplow/index.html#event-schema.
*
* If the library is not initialized and events are trying to be
* dispatched (data-attributes, load-events), they will be added
@@ -49,7 +49,7 @@ export const Tracker = {
* If there is an error when using the library, it will return ´false´
* and ´true´ otherwise.
*
- * @param {...any} eventData defined event taxonomy
+ * @param {...any} eventData defined event schema
* @returns {Boolean}
*/
event(...eventData) {
diff --git a/app/assets/javascripts/users_select/constants.js b/app/assets/javascripts/users_select/constants.js
index 64df1e1748c..c100c1f4ca5 100644
--- a/app/assets/javascripts/users_select/constants.js
+++ b/app/assets/javascripts/users_select/constants.js
@@ -1,11 +1,3 @@
-export const AJAX_USERS_SELECT_OPTIONS_MAP = {
- projectId: 'projectId',
- groupId: 'groupId',
- showCurrentUser: 'currentUser',
- authorId: 'authorId',
- skipUsers: 'skipUsers',
-};
-
export const AJAX_USERS_SELECT_PARAMS_MAP = {
project_id: 'projectId',
group_id: 'groupId',
@@ -15,4 +7,7 @@ export const AJAX_USERS_SELECT_PARAMS_MAP = {
current_user: 'showCurrentUser',
author_id: 'authorId',
skip_users: 'skipUsers',
+ states: 'states',
};
+
+export const ACTIVE_AND_BLOCKED_USER_STATES = ['active', 'blocked'];
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index bd425bdc2a8..7c1204c511c 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -1,21 +1,17 @@
-/* eslint-disable func-names, prefer-rest-params, consistent-return, no-shadow, no-self-compare, no-unused-expressions, yoda, prefer-spread, camelcase, no-param-reassign */
+/* eslint-disable func-names, consistent-return, no-shadow, no-self-compare, no-unused-expressions, camelcase, no-param-reassign */
/* global Issuable */
/* global emitSidebarEvent */
import $ from 'jquery';
import { escape, template, uniqBy } from 'lodash';
-import {
- AJAX_USERS_SELECT_OPTIONS_MAP,
- AJAX_USERS_SELECT_PARAMS_MAP,
-} from 'ee_else_ce/users_select/constants';
+import { AJAX_USERS_SELECT_PARAMS_MAP } from 'ee_else_ce/users_select/constants';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { isUserBusy } from '~/set_status_modal/utils';
import { fixTitle, dispose } from '~/tooltips';
import axios from '~/lib/utils/axios_utils';
import { parseBoolean, spriteIcon } from '~/lib/utils/common_utils';
-import { loadCSSFile } from '~/lib/utils/css_utils';
import { s__, __, sprintf } from '~/locale';
-import { getAjaxUsersSelectOptions, getAjaxUsersSelectParams } from './utils';
+import { getAjaxUsersSelectParams } from './utils';
// TODO: remove eventHub hack after code splitting refactor
window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
@@ -24,9 +20,7 @@ function UsersSelect(currentUser, els, options = {}) {
const elsClassName = els?.toString().match('.(.+$)')[1];
const $els = $(els || '.js-user-search');
this.users = this.users.bind(this);
- this.user = this.user.bind(this);
this.usersPath = '/-/autocomplete/users.json';
- this.userPath = '/-/autocomplete/users/:id.json';
if (currentUser != null) {
if (typeof currentUser === 'object') {
this.currentUser = currentUser;
@@ -35,28 +29,29 @@ function UsersSelect(currentUser, els, options = {}) {
}
}
- const { handleClick } = options;
- const userSelect = this;
+ const { handleClick, states } = options;
$els.each((i, dropdown) => {
const userSelect = this;
- const options = {};
const $dropdown = $(dropdown);
- options.projectId = $dropdown.data('projectId');
- options.groupId = $dropdown.data('groupId');
- options.showCurrentUser = $dropdown.data('currentUser');
- options.todoFilter = $dropdown.data('todoFilter');
- options.todoStateFilter = $dropdown.data('todoStateFilter');
- options.iid = $dropdown.data('iid');
- options.issuableType = $dropdown.data('issuableType');
- options.targetBranch = $dropdown.data('targetBranch');
- options.showSuggested = $dropdown.data('showSuggested');
+ const options = {
+ states,
+ projectId: $dropdown.data('projectId'),
+ groupId: $dropdown.data('groupId'),
+ showCurrentUser: $dropdown.data('currentUser'),
+ todoFilter: $dropdown.data('todoFilter'),
+ todoStateFilter: $dropdown.data('todoStateFilter'),
+ iid: $dropdown.data('iid'),
+ issuableType: $dropdown.data('issuableType'),
+ targetBranch: $dropdown.data('targetBranch'),
+ authorId: $dropdown.data('authorId'),
+ showSuggested: $dropdown.data('showSuggested'),
+ };
const showNullUser = $dropdown.data('nullUser');
const defaultNullUser = $dropdown.data('nullUserDefault');
const showMenuAbove = $dropdown.data('showMenuAbove');
const showAnyUser = $dropdown.data('anyUser');
const firstUser = $dropdown.data('firstUser');
- options.authorId = $dropdown.data('authorId');
const defaultLabel = $dropdown.data('defaultLabel');
const issueURL = $dropdown.data('issueUpdate');
const $selectbox = $dropdown.closest('.selectbox');
@@ -72,6 +67,22 @@ function UsersSelect(currentUser, els, options = {}) {
let assigneeTemplate;
let collapsedAssigneeTemplate;
+ const suggestedReviewersHelpPath = $dropdown.data('suggestedReviewersHelpPath');
+ const suggestedReviewersHeaderTemplate = template(
+ `<div class="gl-display-flex gl-align-items-center">
+ <%- header %>
+ <a
+ title="${s__('SuggestedReviewers|Learn about suggested reviewers')}"
+ href="${suggestedReviewersHelpPath}"
+ rel="noopener"
+ target="_blank"
+ aria-label="${s__('SuggestedReviewers|Suggested reviewers help link')}"
+ class="gl-hover-bg-transparent! gl-p-0! has-tooltip">
+ ${spriteIcon('question-o', 'gl-ml-3 gl-icon s16')}
+ </a>
+ </div>`,
+ );
+
if (selectedId === undefined) {
selectedId = selectedIdDefault;
}
@@ -388,7 +399,12 @@ function UsersSelect(currentUser, els, options = {}) {
if (!suggestedUsers.length) return [];
const items = [
- { type: 'header', content: $dropdown.data('suggestedReviewersHeader') },
+ {
+ type: 'header',
+ content: suggestedReviewersHeaderTemplate({
+ header: $dropdown.data('suggestedReviewersHeader'),
+ }),
+ },
...suggestedUsers,
{ type: 'header', content: $dropdown.data('allMembersHeader') },
];
@@ -431,6 +447,10 @@ function UsersSelect(currentUser, els, options = {}) {
hidden() {
if ($dropdown.hasClass('js-multiselect')) {
if ($dropdown.hasClass(elsClassName)) {
+ if (window.gon?.features?.realtimeReviewers) {
+ $dropdown.data('deprecatedJQueryDropdown').clearMenu();
+ $dropdown.closest('.selectbox').children('input[type="hidden"]').remove();
+ }
emitSidebarEvent('sidebar.saveReviewers');
} else {
emitSidebarEvent('sidebar.saveAssignees');
@@ -615,156 +635,8 @@ function UsersSelect(currentUser, els, options = {}) {
},
});
});
-
- if ($('.ajax-users-select').length) {
- import(/* webpackChunkName: 'select2' */ 'select2/select2')
- .then(() => {
- // eslint-disable-next-line promise/no-nesting
- loadCSSFile(gon.select2_css_path)
- .then(() => {
- $('.ajax-users-select').each((i, select) => {
- const options = getAjaxUsersSelectOptions($(select), AJAX_USERS_SELECT_OPTIONS_MAP);
- options.skipLdap = $(select).hasClass('skip_ldap');
- const showNullUser = $(select).data('nullUser');
- const showAnyUser = $(select).data('anyUser');
- const showEmailUser = $(select).data('emailUser');
- const firstUser = $(select).data('firstUser');
- return $(select).select2({
- placeholder: __('Search for a user'),
- multiple: $(select).hasClass('multiselect'),
- minimumInputLength: 0,
- query(query) {
- return userSelect.users(query.term, options, (users) => {
- let name;
- const data = {
- results: users,
- };
- if (query.term.length === 0) {
- if (firstUser) {
- // Move current user to the front of the list
- const ref = data.results;
-
- for (let index = 0, len = ref.length; index < len; index += 1) {
- const obj = ref[index];
- if (obj.username === firstUser) {
- data.results.splice(index, 1);
- data.results.unshift(obj);
- break;
- }
- }
- }
- if (showNullUser) {
- const nullUser = {
- name: s__('UsersSelect|Unassigned'),
- id: 0,
- };
- data.results.unshift(nullUser);
- }
- if (showAnyUser) {
- name = showAnyUser;
- if (name === true) {
- name = s__('UsersSelect|Any User');
- }
- const anyUser = {
- name,
- id: null,
- };
- data.results.unshift(anyUser);
- }
- }
- if (
- showEmailUser &&
- data.results.length === 0 &&
- query.term.match(/^[^@]+@[^@]+$/)
- ) {
- const trimmed = query.term.trim();
- const emailUser = {
- name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }),
- username: trimmed,
- id: trimmed,
- invite: true,
- };
- data.results.unshift(emailUser);
- }
- return query.callback(data);
- });
- },
- initSelection() {
- const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
- return userSelect.initSelection.apply(userSelect, args);
- },
- formatResult() {
- const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
- return userSelect.formatResult.apply(userSelect, args);
- },
- formatSelection() {
- const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
- return userSelect.formatSelection.apply(userSelect, args);
- },
- dropdownCssClass: 'ajax-users-dropdown',
- // we do not want to escape markup since we are displaying html in results
- escapeMarkup(m) {
- return m;
- },
- });
- });
- })
- .catch(() => {});
- })
- .catch(() => {});
- }
}
-UsersSelect.prototype.initSelection = function (element, callback) {
- const id = $(element).val();
- if (id === '0') {
- const nullUser = {
- name: s__('UsersSelect|Unassigned'),
- };
- return callback(nullUser);
- } else if (id !== '') {
- return this.user(id, callback);
- }
-};
-
-UsersSelect.prototype.formatResult = function (user) {
- let avatar = gon.default_avatar_url;
- if (user.avatar_url) {
- avatar = user.avatar_url;
- }
- return `
- <div class='user-result'>
- <div class='user-image'>
- <img class='avatar avatar-inline s32' src='${avatar}'>
- </div>
- <div class='user-info'>
- <div class='user-name dropdown-menu-user-full-name'>
- ${escape(user.name)}
- </div>
- <div class='user-username dropdown-menu-user-username text-secondary'>
- ${!user.invite ? `@${escape(user.username)}` : ''}
- </div>
- </div>
- </div>
- `;
-};
-
-UsersSelect.prototype.formatSelection = function (user) {
- return escape(user.name);
-};
-
-UsersSelect.prototype.user = function (user_id, callback) {
- if (!/^\d+$/.test(user_id)) {
- return false;
- }
-
- let url = this.buildUrl(this.userPath);
- url = url.replace(':id', user_id);
- return axios.get(url).then(({ data }) => {
- callback(data);
- });
-};
-
// Return users list. Filtered by query
// Only active users retrieved
UsersSelect.prototype.users = function (query, options, callback) {
diff --git a/app/assets/javascripts/users_select/utils.js b/app/assets/javascripts/users_select/utils.js
index b46fd15fb77..8184e646124 100644
--- a/app/assets/javascripts/users_select/utils.js
+++ b/app/assets/javascripts/users_select/utils.js
@@ -1,18 +1,4 @@
/**
- * Get options from data attributes on passed `$select`.
- * @param {jQuery} $select
- * @param {Object} optionsMap e.g. { optionKeyName: 'dataAttributeName' }
- */
-export const getAjaxUsersSelectOptions = ($select, optionsMap) => {
- return Object.keys(optionsMap).reduce((accumulator, optionKey) => {
- const dataKey = optionsMap[optionKey];
- accumulator[optionKey] = $select.data(dataKey);
-
- return accumulator;
- }, {});
-};
-
-/**
* Get query parameters used for users request from passed `options` parameter
* @param {Object} options e.g. { currentUserId: 1, fooBar: 'baz' }
* @param {Object} paramsMap e.g. { user_id: 'currentUserId', foo_bar: 'fooBar' }
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
index 30098f7619a..2132c2953d9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
@@ -1,7 +1,6 @@
<script>
-import { GlLink, GlTooltipDirective } from '@gitlab/ui';
+import { GlLink, GlTooltipDirective, GlTruncate } from '@gitlab/ui';
import { __ } from '~/locale';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import {
MANUAL_DEPLOY,
@@ -18,7 +17,7 @@ export default {
components: {
GlLink,
MemoryUsage: () => import('./memory_usage.vue'),
- TooltipOnTruncate,
+ GlTruncate,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -74,16 +73,19 @@ export default {
<div class="js-deployment-info deployment-info">
<template v-if="hasDeploymentMeta">
<span>{{ deployedText }}</span>
- <tooltip-on-truncate :title="deployment.name" truncate-target="child" class="label-truncate">
- <gl-link
- :href="deployment.url"
- target="_blank"
- rel="noopener noreferrer nofollow"
- class="js-deploy-meta gl-font-sm gl-pb-1"
- >
- {{ deployment.name }}
- </gl-link>
- </tooltip-on-truncate>
+ <gl-link
+ :href="deployment.url"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ class="js-deploy-meta gl-font-sm gl-pb-1"
+ >
+ <gl-truncate
+ class="js-deploy-env-name"
+ :text="deployment.name"
+ position="middle"
+ with-tooltip
+ />
+ </gl-link>
</template>
<span
v-if="hasDeploymentTime"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue
index 655acf28253..501f5f1523f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue
@@ -67,7 +67,6 @@ export default {
v-for="deployment in deployments"
:key="deployment.id"
:class="deploymentClass"
- class="gl-bg-gray-50"
:deployment="deployment"
:show-metrics="hasDeploymentMetrics"
/>
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 5efa0e2879e..39a6086e0d5 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
@@ -72,7 +72,7 @@ export default {
:display="appButtonText"
:link="deploymentExternalUrl"
size="small"
- css-class="deploy-link js-deploy-url inline gl-ml-3"
+ css-class="deploy-link js-deploy-url inline"
/>
<modal-copy-button
v-else
@@ -116,7 +116,7 @@ export default {
:display="appButtonText"
:link="deploymentExternalUrl"
size="small"
- css-class="deploy-link js-deploy-url inline gl-ml-3"
+ css-class="deploy-link js-deploy-url inline"
/>
<modal-copy-button
v-else
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js
index a58d524b9ed..cc7b9d0ea72 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js
@@ -11,8 +11,6 @@ export default {
render(h) {
const { extensions } = registeredExtensions;
- if (extensions.length === 0) return null;
-
return h(
'section',
{
@@ -22,26 +20,28 @@ export default {
},
},
[
- h(
- 'ul',
- {
- class: 'gl-p-0 gl-m-0 gl-list-style-none',
- },
- [
- ...extensions.map((extension, index) =>
- h('li', { attrs: { class: index > 0 && 'mr-widget-border-top' } }, [
- h(
- { ...extension },
- {
- props: {
- mr: this.mr,
+ h('div', { attrs: { class: 'mr-widget-section' } }, [
+ h(
+ 'ul',
+ {
+ class: 'gl-p-0 gl-m-0 gl-list-style-none',
+ },
+ [
+ ...extensions.map((extension, index) =>
+ h('li', { attrs: { class: index > 0 && 'mr-widget-border-top' } }, [
+ h(
+ { ...extension },
+ {
+ props: {
+ mr: this.mr,
+ },
},
- },
- ),
- ]),
- ),
- ],
- ),
+ ),
+ ]),
+ ),
+ ],
+ ),
+ ]),
],
);
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
index f8d2732b385..6475def461a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
@@ -46,8 +46,8 @@ export default {
};
</script>
<template>
- <div>
- <div class="mr-widget-extension d-flex align-items-center pl-3">
+ <div class="mr-widget-extension">
+ <div class="d-flex align-items-center pl-3">
<div v-if="hasError" class="ci-widget media">
<div class="media-body">
<span class="gl-font-sm mr-widget-margin-left gl-line-height-24 js-error-state">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_container.vue
index 5967ca026e5..4445448fcd7 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_container.vue
@@ -1,6 +1,8 @@
<template>
- <div class="mr-widget-heading">
- <div class="mr-widget-content"><slot name="default"></slot></div>
- <slot name="footer"></slot>
+ <div class="mr-section-container mr-widget-workflow">
+ <div class="mr-widget-section">
+ <div class="mr-widget-content"><slot name="default"></slot></div>
+ <slot name="footer"></slot>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index fe69e96bd87..97c6de37054 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
@@ -200,7 +200,7 @@ export default {
data-testid="pipeline-info-container"
data-qa-selector="merge_request_pipeline_info_content"
>
- {{ pipeline.details.name }}
+ {{ pipeline.details.event_type_name || pipeline.details.name }}
<gl-link
:href="pipeline.path"
class="pipeline-id gl-font-weight-normal pipeline-number"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
index a05e8747a43..010c172c710 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
@@ -80,13 +80,7 @@ export default {
);
},
preferredAutoMergeStrategy() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return MergeRequestStore.getPreferredAutoMergeStrategy(
- this.mr.availableAutoMergeStrategies,
- );
- }
-
- return this.mr.preferredAutoMergeStrategy;
+ return MergeRequestStore.getPreferredAutoMergeStrategy(this.mr.availableAutoMergeStrategies);
},
ciStatus() {
return this.isPostMerge ? this.mr?.mergePipeline?.details?.status?.text : this.mr.ciStatus;
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue
new file mode 100644
index 00000000000..2683956e603
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue
@@ -0,0 +1,18 @@
+<script>
+export default {
+ data() {
+ return {
+ hasChildren: false,
+ };
+ },
+ updated() {
+ this.hasChildren = this.$scopedSlots.default?.()?.some((c) => c.tag);
+ },
+};
+</script>
+
+<template>
+ <div v-show="hasChildren" class="mr-section-container mr-widget-workflow">
+ <slot></slot>
+ </div>
+</template>
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 932659f3c89..66e33a08a12 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
@@ -45,12 +45,19 @@ export default {
if (this.status === 'closed') return 'gl-bg-red-50';
return null;
},
+ hasActionsSlot() {
+ return this.$scopedSlots.actions?.()?.length;
+ },
},
};
</script>
<template>
- <div class="mr-widget-body media" :class="wrapperClasses" v-on="$listeners">
+ <div
+ class="mr-widget-body media mr-widget-body-line-height-1 gl-line-height-normal"
+ :class="wrapperClasses"
+ v-on="$listeners"
+ >
<div v-if="isLoading" class="gl-w-full mr-conflict-loader">
<slot name="loading">
<div class="gl-display-flex">
@@ -67,13 +74,19 @@ export default {
</slot>
<div class="gl-display-flex gl-w-full">
<div
- :class="{ 'gl-display-flex': actions.length, 'gl-md-display-flex': !actions.length }"
+ :class="{
+ 'gl-display-flex gl-align-items-center': actions.length,
+ 'gl-md-display-flex gl-align-items-center': !actions.length,
+ }"
class="media-body"
>
<slot></slot>
<div
- :class="{ 'gl-flex-direction-column-reverse': !actions.length }"
- class="gl-display-flex gl-md-display-block gl-font-size-0 gl-ml-auto"
+ :class="{
+ 'state-container-action-buttons gl-flex-direction-column gl-flex-wrap gl-justify-content-end': !actions.length,
+ 'gl-md-pt-0 gl-pt-3': hasActionsSlot,
+ }"
+ class="gl-display-flex gl-font-size-0 gl-ml-auto gl-gap-3"
>
<slot name="actions">
<actions v-if="actions.length" :tertiary-buttons="actions" />
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 2b22033514f..38b99dae264 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
@@ -9,6 +9,7 @@ export default {
blockingMergeRequests: s__(
'mrWidget|Merge blocked: you can only merge after the above items are resolved.',
),
+ externalStatusChecksFailed: s__('mrWidget|Merge blocked: all status checks must pass.'),
},
components: {
StatusIcon,
@@ -25,6 +26,8 @@ export default {
return this.$options.i18n.approvalNeeded;
} else if (this.mr.detailedMergeStatus === DETAILED_MERGE_STATUS.BLOCKED_STATUS) {
return this.$options.i18n.blockingMergeRequests;
+ } else if (this.mr.detailedMergeStatus === DETAILED_MERGE_STATUS.EXTERNAL_STATUS_CHECKS) {
+ return this.$options.i18n.externalStatusChecksFailed;
}
return null;
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 92a7fa39cdc..38f7d3d2c96 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
@@ -3,8 +3,8 @@ 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 { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { AUTO_MERGE_STRATEGIES } from '../../constants';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
@@ -16,9 +16,6 @@ export default {
apollo: {
state: {
query: autoMergeEnabledQuery,
- skip() {
- return !this.glFeatures.mergeRequestWidgetGraphql;
- },
variables() {
return this.mergeRequestQueryVariables;
},
@@ -31,7 +28,7 @@ export default {
GlSprintf,
StateContainer,
},
- mixins: [autoMergeMixin, glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
+ mixins: [autoMergeMixin, mergeRequestQueryVariablesMixin],
props: {
mr: {
type: Object,
@@ -51,31 +48,21 @@ export default {
},
computed: {
loading() {
- return (
- this.glFeatures.mergeRequestWidgetGraphql &&
- this.$apollo.queries.state.loading &&
- Object.keys(this.state).length === 0
- );
- },
- mergeUser() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.state.mergeUser;
- }
-
- return this.mr.setToAutoMergeBy;
+ return this.$apollo.queries.state.loading && Object.keys(this.state).length === 0;
},
- targetBranch() {
- return (this.glFeatures.mergeRequestWidgetGraphql ? this.state : this.mr).targetBranch;
- },
- shouldRemoveSourceBranch() {
- if (!this.glFeatures.mergeRequestWidgetGraphql) return this.mr.shouldRemoveSourceBranch;
-
+ stateRemoveSourceBranch() {
if (!this.state.shouldRemoveSourceBranch) return false;
return this.state.shouldRemoveSourceBranch || this.state.forceRemoveSourceBranch;
},
- autoMergeStrategy() {
- return (this.glFeatures.mergeRequestWidgetGraphql ? this.state : this.mr).autoMergeStrategy;
+ canRemoveSourceBranch() {
+ const { currentUserId } = this.mr;
+ const mergeUserId = getIdFromGraphQLId(this.state.mergeUser?.id);
+ const canRemoveSourceBranch = this.state.userPermissions.removeSourceBranch;
+
+ return (
+ !this.stateRemoveSourceBranch && canRemoveSourceBranch && mergeUserId === currentUserId
+ );
},
actions() {
const actions = [];
@@ -104,12 +91,8 @@ export default {
this.service
.cancelAutomaticMerge()
.then((res) => res.data)
- .then((data) => {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- eventHub.$emit('MRWidgetUpdateRequested');
- } else {
- eventHub.$emit('UpdateWidgetData', data);
- }
+ .then(() => {
+ eventHub.$emit('MRWidgetUpdateRequested');
})
.catch(() => {
this.isCancellingAutoMerge = false;
@@ -121,7 +104,7 @@ export default {
removeSourceBranch() {
const options = {
sha: this.mr.sha,
- auto_merge_strategy: this.autoMergeStrategy,
+ auto_merge_strategy: this.state.autoMergeStrategy,
should_remove_source_branch: true,
};
@@ -135,9 +118,7 @@ export default {
}
})
.then(() => {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- this.$apollo.queries.state.refetch();
- }
+ this.$apollo.queries.state.refetch();
})
.catch(() => {
this.isRemovingSourceBranch = false;
@@ -162,7 +143,7 @@ export default {
<h4 class="gl-mr-3" data-testid="statusText">
<gl-sprintf :message="statusText" data-testid="statusText">
<template #merge_author>
- <mr-widget-author :author="mergeUser" />
+ <mr-widget-author v-if="state.mergeUser" :author="state.mergeUser" />
</template>
</gl-sprintf>
</h4>
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 39c56cbb93d..448805cf8b9 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
@@ -1,6 +1,5 @@
<script>
import { s__ } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import autoMergeFailedQuery from '../../queries/states/auto_merge_failed.query.graphql';
@@ -11,13 +10,10 @@ export default {
components: {
StateContainer,
},
- mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
+ mixins: [mergeRequestQueryVariablesMixin],
apollo: {
mergeError: {
query: autoMergeFailedQuery,
- skip() {
- return !this.glFeatures.mergeRequestWidgetGraphql;
- },
variables() {
return this.mergeRequestQueryVariables;
},
@@ -32,7 +28,7 @@ export default {
},
data() {
return {
- mergeError: this.glFeatures.mergeRequestWidgetGraphql ? null : this.mr.mergeError,
+ mergeError: null,
isRefreshing: false,
};
},
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 d60d3cfc9ea..8e1b18c63a4 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -1,6 +1,5 @@
<script>
import { GlButton, GlSkeletonLoader } from '@gitlab/ui';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import userPermissionsQuery from '../../queries/permissions.query.graphql';
import conflictsStateQuery from '../../queries/states/conflicts.query.graphql';
@@ -13,23 +12,17 @@ export default {
GlButton,
StateContainer,
},
- mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
+ mixins: [mergeRequestQueryVariablesMixin],
apollo: {
userPermissions: {
query: userPermissionsQuery,
- skip() {
- return !this.glFeatures.mergeRequestWidgetGraphql;
- },
variables() {
return this.mergeRequestQueryVariables;
},
update: (data) => data.project.mergeRequest.userPermissions,
},
- stateData: {
+ state: {
query: conflictsStateQuery,
- skip() {
- return !this.glFeatures.mergeRequestWidgetGraphql;
- },
variables() {
return this.mergeRequestQueryVariables;
},
@@ -47,40 +40,19 @@ export default {
data() {
return {
userPermissions: {},
- stateData: {},
+ state: {},
};
},
computed: {
isLoading() {
- return (
- this.glFeatures.mergeRequestWidgetGraphql &&
- this.$apollo.queries.userPermissions.loading &&
- this.$apollo.queries.stateData.loading
- );
- },
- canPushToSourceBranch() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.userPermissions.pushToSourceBranch;
- }
-
- return this.mr.canPushToSourceBranch;
- },
- canMerge() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.userPermissions.canMerge;
- }
-
- return this.mr.canMerge;
- },
- shouldBeRebased() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.stateData.shouldBeRebased;
- }
-
- return this.mr.shouldBeRebased;
+ return this.$apollo.queries.userPermissions.loading && this.$apollo.queries.state.loading;
},
showResolveButton() {
- return this.mr.conflictResolutionPath && this.canPushToSourceBranch;
+ return (
+ this.mr.conflictResolutionPath &&
+ this.userPermissions.pushToSourceBranch &&
+ !this.state.sourceBranchProtected
+ );
},
},
};
@@ -95,7 +67,7 @@ export default {
</gl-skeleton-loader>
</template>
<template v-if="!isLoading">
- <span v-if="shouldBeRebased" class="bold gl-ml-0! gl-text-body!">
+ <span v-if="state.shouldBeRebased" class="bold gl-ml-0! gl-text-body!">
{{
s__(`mrWidget|Merge blocked: fast-forward merge is not possible.
To merge this request, first rebase locally.`)
@@ -104,7 +76,7 @@ export default {
<template v-else>
<span class="bold gl-ml-0! gl-text-body! gl-flex-grow-1 gl-w-full gl-md-w-auto gl-mr-2">
{{ s__('mrWidget|Merge blocked: merge conflicts must be resolved.') }}
- <span v-if="!canMerge">
+ <span v-if="!userPermissions.canMerge">
{{
s__(
`mrWidget|Users who can write to the source or target branches can resolve the conflicts.`,
@@ -114,9 +86,9 @@ export default {
</span>
</template>
</template>
- <template v-if="!isLoading && !shouldBeRebased" #actions>
+ <template v-if="!isLoading && !state.shouldBeRebased" #actions>
<gl-button
- v-if="canMerge"
+ v-if="userPermissions.canMerge"
size="small"
variant="confirm"
category="secondary"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
index 214d1b49732..5e073bf7c04 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
@@ -1,7 +1,6 @@
<script>
import { GlIcon, GlTooltipDirective, GlSprintf } from '@gitlab/ui';
import { sprintf } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import missingBranchQuery from '../../queries/states/missing_branch.query.graphql';
import {
@@ -21,13 +20,10 @@ export default {
GlSprintf,
StatusIcon,
},
- mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
+ mixins: [mergeRequestQueryVariablesMixin],
apollo: {
state: {
query: missingBranchQuery,
- skip() {
- return !this.glFeatures.mergeRequestWidgetGraphql;
- },
variables() {
return this.mergeRequestQueryVariables;
},
@@ -44,15 +40,8 @@ export default {
return { state: {} };
},
computed: {
- sourceBranchRemoved() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return !this.state.sourceBranchExists;
- }
-
- return this.mr.sourceBranchRemoved;
- },
type() {
- return this.sourceBranchRemoved ? 'source' : 'target';
+ return this.mr.sourceBranchRemoved ? 'source' : 'target';
},
name() {
return this.type === 'source' ? this.mr.sourceBranch : this.mr.targetBranch;
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 f6843c1f3d3..4ae4edf02c3 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
@@ -2,7 +2,6 @@
import { GlButton, GlSkeletonLoader } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { __ } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import toast from '~/vue_shared/plugins/global_toast';
import simplePoll from '~/lib/utils/simple_poll';
import eventHub from '../../event_hub';
@@ -15,9 +14,6 @@ export default {
apollo: {
state: {
query: rebaseQuery,
- skip() {
- return !this.glFeatures.mergeRequestWidgetGraphql;
- },
variables() {
return this.mergeRequestQueryVariables;
},
@@ -29,7 +25,7 @@ export default {
GlButton,
StateContainer,
},
- mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
+ mixins: [mergeRequestQueryVariablesMixin],
props: {
mr: {
type: Object,
@@ -49,28 +45,16 @@ export default {
},
computed: {
isLoading() {
- return this.glFeatures.mergeRequestWidgetGraphql && this.$apollo.queries.state.loading;
+ return this.$apollo.queries.state.loading;
},
rebaseInProgress() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.state.rebaseInProgress;
- }
-
- return this.mr.rebaseInProgress;
+ return this.state.rebaseInProgress;
},
canPushToSourceBranch() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.state.userPermissions.pushToSourceBranch;
- }
-
- return this.mr.canPushToSourceBranch;
+ return this.state.userPermissions.pushToSourceBranch;
},
targetBranch() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.state.targetBranch;
- }
-
- return this.mr.targetBranch;
+ return this.state.targetBranch;
},
status() {
if (this.isLoading) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue
index 0b6aa104181..2db5c71be82 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue
@@ -8,7 +8,7 @@ export default {
canMerge: {
query: readyToMergeQuery,
skip() {
- return !this.mr || !window.gon?.features?.mergeRequestWidgetGraphql;
+ return !this.mr;
},
variables() {
return this.mergeRequestQueryVariables;
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 1298c1316e2..c54672cd0f8 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
@@ -19,7 +19,6 @@ import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import simplePoll from '~/lib/utils/simple_poll';
import { __, s__, n__ } from '~/locale';
import SmartInterval from '~/smart_interval';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { helpPagePath } from '~/helpers/help_page_helper';
import {
AUTO_MERGE_STRATEGIES,
@@ -54,9 +53,6 @@ export default {
apollo: {
state: {
query: readyToMergeQuery,
- skip() {
- return !this.glFeatures.mergeRequestWidgetGraphql;
- },
variables() {
return this.mergeRequestQueryVariables;
},
@@ -123,14 +119,14 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [readyToMergeMixin, glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
+ mixins: [readyToMergeMixin, mergeRequestQueryVariablesMixin],
props: {
mr: { type: Object, required: true },
service: { type: Object, required: true },
},
data() {
return {
- loading: this.glFeatures.mergeRequestWidgetGraphql,
+ loading: true,
state: {},
removeSourceBranch: this.mr.shouldRemoveSourceBranch,
isMakingRequest: false,
@@ -148,7 +144,7 @@ export default {
},
computed: {
stateData() {
- return this.glFeatures.mergeRequestWidgetGraphql ? this.state : this.mr;
+ return this.state;
},
hasCI() {
return this.stateData.hasCI || this.stateData.hasCi;
@@ -157,35 +153,19 @@ export default {
return !isEmpty(this.stateData.availableAutoMergeStrategies);
},
pipeline() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.state.headPipeline;
- }
-
- return this.mr.pipeline;
+ return this.state.headPipeline;
},
isPipelineFailed() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return ['FAILED', 'CANCELED'].indexOf(this.pipeline?.status) !== -1;
- }
-
- return this.mr.isPipelineFailed;
+ return ['FAILED', 'CANCELED'].indexOf(this.pipeline?.status) !== -1;
},
showMergeFailedPipelineConfirmationDialog() {
return this.status === PIPELINE_FAILED_STATE && this.isPipelineFailed;
},
isMergeAllowed() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.state.mergeable;
- }
-
- return this.mr.isMergeAllowed;
+ return this.state.mergeable || false;
},
canRemoveSourceBranch() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.state.userPermissions.removeSourceBranch;
- }
-
- return this.mr.canRemoveSourceBranch;
+ return this.state.userPermissions.removeSourceBranch;
},
commitTemplateHelpPage() {
return helpPagePath('user/project/merge_requests/commit_templates.md');
@@ -200,46 +180,24 @@ export default {
return this.$options.i18n.mergeCommitTemplateHintText;
},
commits() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.state.commitsWithoutMergeCommits.nodes;
- }
-
- return this.mr.commits;
+ return this.state.commitsWithoutMergeCommits?.nodes;
},
commitsCount() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.state.commitCount || 0;
- }
-
- return this.mr.commitsCount;
+ return this.state.commitCount || 0;
},
preferredAutoMergeStrategy() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return MergeRequestStore.getPreferredAutoMergeStrategy(
- this.state.availableAutoMergeStrategies,
- );
- }
-
- return this.mr.preferredAutoMergeStrategy;
+ return MergeRequestStore.getPreferredAutoMergeStrategy(
+ this.state.availableAutoMergeStrategies,
+ );
},
squashIsSelected() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.isSquashReadOnly ? this.state.squashOnMerge : this.state.squash;
- }
-
- return this.mr.squashIsSelected;
+ return this.isSquashReadOnly ? this.state.squashOnMerge : this.state.squash;
},
isPipelineActive() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.pipeline?.active || false;
- }
-
- return this.mr.isPipelineActive;
+ return this.pipeline?.active || false;
},
status() {
- const ciStatus = this.glFeatures.mergeRequestWidgetGraphql
- ? this.pipeline?.status.toLowerCase()
- : this.mr.ciStatus;
+ const ciStatus = this.pipeline?.status?.toLowerCase();
if ((this.hasCI && !ciStatus) || this.hasPipelineMustSucceedConflict) {
return PIPELINE_FAILED_STATE;
@@ -304,11 +262,7 @@ export default {
return this.squashBeforeMerge && this.shouldShowSquashBeforeMerge;
},
shouldShowMergeEdit() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return !this.state.mergeRequestsFfOnlyEnabled;
- }
-
- return !this.mr.ffOnlyEnabled;
+ return !this.state.mergeRequestsFfOnlyEnabled;
},
shaMismatchLink() {
return this.mr.mergeRequestDiffsPath;
@@ -345,18 +299,15 @@ export default {
},
},
mounted() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- eventHub.$on('ApprovalUpdated', this.updateGraphqlState);
- eventHub.$on('MRWidgetUpdateRequested', this.updateGraphqlState);
- eventHub.$on('mr.discussion.updated', this.updateGraphqlState);
- }
+ eventHub.$on('ApprovalUpdated', this.updateGraphqlState);
+ eventHub.$on('MRWidgetUpdateRequested', this.updateGraphqlState);
+ eventHub.$on('mr.discussion.updated', this.updateGraphqlState);
},
beforeDestroy() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- eventHub.$off('ApprovalUpdated', this.updateGraphqlState);
- eventHub.$off('MRWidgetUpdateRequested', this.updateGraphqlState);
- eventHub.$off('mr.discussion.updated', this.updateGraphqlState);
- }
+ eventHub.$off('ApprovalUpdated', this.updateGraphqlState);
+ eventHub.$off('MRWidgetUpdateRequested', this.updateGraphqlState);
+ eventHub.$off('mr.discussion.updated', this.updateGraphqlState);
+ eventHub.$off('ApprovalUpdated', this.updateGraphqlState);
if (this.pollingInterval) {
this.pollingInterval.destroy();
@@ -391,9 +342,7 @@ export default {
if (mergeImmediately) {
this.isMergingImmediately = true;
}
- const latestSha = this.glFeatures.mergeRequestWidgetGraphql
- ? this.state.diffHeadSha
- : this.mr.latestSHA;
+ const latestSha = this.state.diffHeadSha;
const options = {
sha: latestSha || this.mr.sha,
@@ -435,9 +384,7 @@ export default {
this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
}
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- this.updateGraphqlState();
- }
+ this.updateGraphqlState();
this.isMakingRequest = false;
})
@@ -521,7 +468,7 @@ export default {
<template>
<div
data-testid="ready_to_merge_state"
- class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
+ class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7"
>
<div v-if="loading" class="mr-widget-body">
<div class="gl-w-full mr-ready-to-merge-loader">
@@ -538,13 +485,15 @@ export default {
<div class="media-body">
<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-align-items-center gl-flex-wrap gl-w-full gl-mb-5">
+ <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"
+ >
<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"
+ class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center gl-mr-5 gl-mb-3 gl-md-mb-0"
>
{{ __('Delete source branch') }}
</gl-form-checkbox>
@@ -555,37 +504,18 @@ export default {
v-model="squashBeforeMerge"
:help-path="mr.squashBeforeMergeHelpPath"
:is-disabled="isSquashReadOnly"
- class="gl-mr-5"
+ class="gl-mr-5 gl-mb-3 gl-md-mb-0"
/>
<gl-form-checkbox
v-if="shouldShowSquashEdit || shouldShowMergeEdit"
v-model="editCommitMessage"
data-testid="widget_edit_commit_message"
- class="gl-display-flex gl-align-items-center"
+ class="gl-display-flex gl-align-items-center gl-mb-3 gl-md-mb-0"
>
{{ __('Edit commit message') }}
</gl-form-checkbox>
</div>
- <div class="gl-w-full gl-text-gray-500 gl-mb-5">
- <added-commit-message
- :is-squash-enabled="squashBeforeMerge"
- :is-fast-forward-enabled="!shouldShowMergeEdit"
- :commits-count="commitsCount"
- :target-branch="stateData.targetBranch"
- />
- <template v-if="mr.relatedLinks">
- &middot;
- <related-links
- :state="mr.state"
- :related-links="mr.relatedLinks"
- :show-assign-to-me="false"
- :diverged-commits-count="mr.divergedCommitsCount"
- :target-branch-path="mr.targetBranchPath"
- class="mr-ready-merge-related-links gl-display-inline"
- />
- </template>
- </div>
<div v-if="editCommitMessage" class="gl-w-full" data-testid="edit_commit_message">
<ul class="border-top commits-list flex-list gl-list-style-none gl-p-0 gl-pt-4">
<commit-edit
@@ -625,6 +555,25 @@ 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">
+ <added-commit-message
+ :is-squash-enabled="squashBeforeMerge"
+ :is-fast-forward-enabled="!shouldShowMergeEdit"
+ :commits-count="commitsCount"
+ :target-branch="state.targetBranch"
+ />
+ <template v-if="mr.relatedLinks">
+ &middot;
+ <related-links
+ :state="mr.state"
+ :related-links="mr.relatedLinks"
+ :show-assign-to-me="false"
+ :diverged-commits-count="mr.divergedCommitsCount"
+ :target-branch-path="mr.targetBranchPath"
+ class="mr-ready-merge-related-links gl-display-inline"
+ />
+ </template>
+ </div>
<gl-button-group class="gl-align-self-start">
<gl-button
size="medium"
@@ -702,7 +651,7 @@ export default {
:is-squash-enabled="squashBeforeMerge"
:is-fast-forward-enabled="!shouldShowMergeEdit"
:commits-count="commitsCount"
- :target-branch="stateData.targetBranch"
+ :target-branch="state.targetBranch"
:merge-commit-path="mr.mergeCommitPath"
/>
</li>
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 8f2e4eb2131..074758e33b2 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
@@ -26,30 +26,30 @@ export default {
<template>
<state-container :mr="mr" status="failed">
<span
- class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!"
+ class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body! gl-align-self-start"
>
{{ s__('mrWidget|Merge blocked: all threads must be resolved.') }}
</span>
<template #actions>
<gl-button
- v-if="mr.createIssueToResolveDiscussionsPath"
- :href="mr.createIssueToResolveDiscussionsPath"
- class="js-create-issue gl-align-self-start gl-vertical-align-top gl-mr-2"
+ data-testid="jump-to-first"
+ class="gl-align-self-start gl-vertical-align-top"
size="small"
variant="confirm"
- category="secondary"
+ category="primary"
+ @click="jumpToFirstUnresolvedDiscussion"
>
- {{ s__('mrWidget|Create issue to resolve all threads') }}
+ {{ s__('mrWidget|Jump to first unresolved thread') }}
</gl-button>
<gl-button
- data-testid="jump-to-first"
- class="gl-mb-2 gl-md-mb-0 gl-align-self-start gl-vertical-align-top"
+ v-if="mr.createIssueToResolveDiscussionsPath"
+ :href="mr.createIssueToResolveDiscussionsPath"
+ class="js-create-issue gl-align-self-start gl-vertical-align-top"
size="small"
variant="confirm"
- category="primary"
- @click="jumpToFirstUnresolvedDiscussion"
+ category="secondary"
>
- {{ s__('mrWidget|Jump to first unresolved thread') }}
+ {{ s__('mrWidget|Create issue to resolve all threads') }}
</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 dee27a5d5b5..ef5be0fbfcd 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
@@ -5,14 +5,12 @@ import $ from 'jquery';
import { createAlert } from '~/flash';
import toast from '~/vue_shared/plugins/global_toast';
import { __ } from '~/locale';
-import MergeRequest from '~/merge_request';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import getStateQuery from '../../queries/get_state.query.graphql';
import draftQuery from '../../queries/states/draft.query.graphql';
import removeDraftMutation from '../../queries/toggle_draft.mutation.graphql';
import StateContainer from '../state_container.vue';
+import eventHub from '../../event_hub';
export default {
name: 'WorkInProgress',
@@ -20,13 +18,10 @@ export default {
GlButton,
StateContainer,
},
- mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
+ mixins: [mergeRequestQueryVariablesMixin],
apollo: {
userPermissions: {
query: draftQuery,
- skip() {
- return !this.glFeatures.mergeRequestWidgetGraphql;
- },
variables() {
return this.mergeRequestQueryVariables;
},
@@ -35,7 +30,6 @@ export default {
},
props: {
mr: { type: Object, required: true },
- service: { type: Object, required: true },
},
data() {
return {
@@ -43,17 +37,8 @@ export default {
isMakingRequest: false,
};
},
- computed: {
- canUpdate() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.userPermissions.updateMergeRequest;
- }
-
- return Boolean(this.mr.removeWIPPath);
- },
- },
methods: {
- removeDraftMutation() {
+ handleRemoveDraft() {
const { mergeRequestQueryVariables } = this;
this.isMakingRequest = true;
@@ -138,26 +123,6 @@ export default {
this.isMakingRequest = false;
});
},
- handleRemoveDraft() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- this.removeDraftMutation();
- } else {
- this.isMakingRequest = true;
- this.service
- .removeWIP()
- .then((res) => res.data)
- .then((data) => {
- eventHub.$emit('UpdateWidgetData', data);
- MergeRequest.toggleDraftStatus(this.mr.title, true);
- })
- .catch(() => {
- this.isMakingRequest = false;
- createAlert({
- message: __('Something went wrong. Please try again.'),
- });
- });
- }
- },
},
};
</script>
@@ -169,12 +134,13 @@ export default {
</span>
<template #actions>
<gl-button
- v-if="canUpdate"
+ v-if="userPermissions.updateMergeRequest"
size="small"
:disabled="isMakingRequest"
:loading="isMakingRequest"
variant="confirm"
class="js-remove-draft gl-md-ml-3 gl-align-self-start"
+ data-testid="removeWipButton"
@click="handleRemoveDraft"
>
{{ s__('mrWidget|Mark as ready') }}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue
index d1ade2886f4..4d66c75719b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue
@@ -58,6 +58,7 @@ export default {
:status-icon-name="statusIcon"
:widget-name="widgetName"
:header="data.header"
+ :help-popover="data.helpPopover"
>
<template #body>
<div class="gl-display-flex gl-flex-direction-column">
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 ff17de343d6..181b8cfad9a 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
@@ -34,8 +34,8 @@ export default {
iconAriaLabel() {
return `${capitalizeFirstCharacter(this.iconName)} ${this.name}`;
},
- iconSize() {
- return this.level === 1 ? 16 : 12;
+ iconClassNameText() {
+ return this.$options.EXTENSION_ICON_CLASS[this.iconName];
},
},
EXTENSION_ICON_NAMES,
@@ -44,24 +44,22 @@ export default {
</script>
<template>
- <div :class="[$options.EXTENSION_ICON_CLASS[iconName]]" class="gl-mr-3">
+ <div
+ :class="{
+ [iconClassNameText]: !isLoading,
+ [`mr-widget-status-icon-level-${level}`]: !isLoading,
+ 'gl-mr-3': level === 1,
+ }"
+ class="gl-relative gl-w-6 gl-h-6 gl-rounded-full gl--flex-center"
+ >
<gl-loading-icon v-if="isLoading" size="md" inline />
- <div
+ <gl-icon
v-else
- class="gl-display-flex gl-align-items-center gl-justify-content-center gl-rounded-full gl-bg-gray-10"
- :class="{
- 'gl-p-2': level === 1,
- }"
- >
- <div class="gl-rounded-full gl-bg-white">
- <gl-icon
- :name="$options.EXTENSION_ICON_NAMES[iconName]"
- :size="iconSize"
- :aria-label="iconAriaLabel"
- :data-qa-selector="`status_${iconName}_icon`"
- class="gl-display-block"
- />
- </div>
- </div>
+ :name="$options.EXTENSION_ICON_NAMES[iconName]"
+ :size="12"
+ :aria-label="iconAriaLabel"
+ :data-qa-selector="`status_${iconName}_icon`"
+ class="gl-relative gl-z-index-1"
+ />
</div>
</template>
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 94359d7d6ac..cea7fb8260a 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
@@ -1,29 +1,41 @@
<script>
-import { GlButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
+import {
+ GlButton,
+ GlLink,
+ GlTooltipDirective,
+ GlLoadingIcon,
+ GlSafeHtmlDirective,
+} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { normalizeHeaders } from '~/lib/utils/common_utils';
import { sprintf, __ } from '~/locale';
import Poll from '~/lib/utils/poll';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
import ActionButtons from '../action_buttons.vue';
import { EXTENSION_ICONS } from '../../constants';
+import { createTelemetryHub } from '../extensions/telemetry';
import ContentRow from './widget_content_row.vue';
import DynamicContent from './dynamic_content.vue';
import StatusIcon from './status_icon.vue';
const FETCH_TYPE_COLLAPSED = 'collapsed';
const FETCH_TYPE_EXPANDED = 'expanded';
+const WIDGET_PREFIX = 'Widget';
export default {
components: {
ActionButtons,
StatusIcon,
+ GlLink,
GlButton,
GlLoadingIcon,
ContentRow,
DynamicContent,
+ HelpPopover,
},
directives: {
GlTooltip: GlTooltipDirective,
+ SafeHtml: GlSafeHtmlDirective,
},
props: {
/**
@@ -72,8 +84,8 @@ export default {
},
statusIconName: {
type: String,
- default: 'neutral',
required: false,
+ default: 'neutral',
validator: (value) => Object.keys(EXTENSION_ICONS).indexOf(value) > -1,
},
isCollapsible: {
@@ -88,6 +100,26 @@ export default {
widgetName: {
type: String,
required: true,
+ // see https://docs.gitlab.com/ee/development/fe_guide/merge_request_widget_extensions.html#add-new-widgets
+ validator: (val) => val.startsWith(WIDGET_PREFIX),
+ },
+ telemetry: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ /**
+ * @typedef {Object} helpPopover
+ * @property {Object} options
+ * @property {String} options.title
+ * @property {Object} content
+ * @property {String} content.text
+ * @property {String} content.learnMorePath
+ */
+ helpPopover: {
+ type: Object,
+ required: false,
+ default: null,
},
},
data() {
@@ -98,6 +130,7 @@ export default {
isLoadingExpandedContent: false,
summaryError: null,
contentError: null,
+ telemetryHub: null,
};
},
computed: {
@@ -113,8 +146,14 @@ export default {
this.$emit('is-loading', newValue);
},
},
+ created() {
+ if (this.telemetry) {
+ this.telemetryHub = createTelemetryHub(this.widgetName);
+ }
+ },
async mounted() {
this.isLoading = true;
+ this.telemetryHub?.viewed();
try {
await this.fetch(this.fetchCollapsedData, FETCH_TYPE_COLLAPSED);
@@ -125,12 +164,21 @@ export default {
this.isLoading = false;
},
methods: {
+ onActionClick(action) {
+ if (action.fullReport) {
+ this.telemetryHub?.fullReportClicked();
+ }
+ },
toggleCollapsed() {
this.isCollapsed = !this.isCollapsed;
- if (this.isExpandedForTheFirstTime && typeof this.fetchExpandedData === 'function') {
- this.isExpandedForTheFirstTime = false;
- this.fetchExpandedContent();
+ if (this.isExpandedForTheFirstTime) {
+ this.telemetryHub?.expanded({ type: this.summaryStatusIcon });
+
+ if (typeof this.fetchExpandedData === 'function') {
+ this.isExpandedForTheFirstTime = false;
+ this.fetchExpandedContent();
+ }
}
},
async fetchExpandedContent() {
@@ -184,6 +232,9 @@ export default {
},
},
failedStatusIcon: EXTENSION_ICONS.failed,
+ i18n: {
+ learnMore: __('Learn more'),
+ },
};
</script>
@@ -204,11 +255,34 @@ export default {
<span v-if="summaryError">{{ summaryError }}</span>
<slot v-else name="summary">{{ isLoading ? loadingText : summary }}</slot>
</div>
- <action-buttons
- v-if="actionButtons.length > 0"
- :widget="widgetName"
- :tertiary-buttons="actionButtons"
- />
+ <div class="gl-display-flex">
+ <help-popover
+ v-if="helpPopover"
+ :options="helpPopover.options"
+ :class="{ 'gl-mr-3': actionButtons.length > 0 }"
+ >
+ <template v-if="helpPopover.content">
+ <p
+ v-if="helpPopover.content.text"
+ v-safe-html="helpPopover.content.text"
+ class="gl-mb-0"
+ ></p>
+ <gl-link
+ v-if="helpPopover.content.learnMorePath"
+ :href="helpPopover.content.learnMorePath"
+ target="_blank"
+ class="gl-font-sm"
+ >{{ $options.i18n.learnMore }}</gl-link
+ >
+ </template>
+ </help-popover>
+ <action-buttons
+ v-if="actionButtons.length > 0"
+ :widget="widgetName"
+ :tertiary-buttons="actionButtons"
+ @clickedAction="onActionClick"
+ />
+ </div>
<div
v-if="isCollapsible"
class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6"
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 ee81f0950a8..1fd1e325863 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
@@ -1,5 +1,8 @@
<script>
-import { GlSafeHtmlDirective } from '@gitlab/ui';
+import { GlSafeHtmlDirective, GlLink } from '@gitlab/ui';
+import { __ } from '~/locale';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
+import ActionButtons from '../action_buttons.vue';
import { EXTENSION_ICONS } from '../../constants';
import { generateText } from '../extensions/utils';
import StatusIcon from './status_icon.vue';
@@ -7,6 +10,9 @@ import StatusIcon from './status_icon.vue';
export default {
components: {
StatusIcon,
+ HelpPopover,
+ GlLink,
+ ActionButtons,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
@@ -19,8 +25,8 @@ export default {
},
statusIconName: {
type: String,
- default: '',
required: false,
+ default: '',
validator: (value) => value === '' || Object.keys(EXTENSION_ICONS).includes(value),
},
widgetName: {
@@ -29,8 +35,26 @@ export default {
},
header: {
type: [String, Array],
+ required: false,
default: '',
+ },
+ /**
+ * @typedef {Object} helpPopover
+ * @property {Object} options
+ * @property {String} options.title
+ * @property {Object} content
+ * @property {String} content.text
+ * @property {String} content.learnMorePath
+ */
+ helpPopover: {
+ type: Object,
required: false,
+ default: null,
+ },
+ actionButtons: {
+ type: Array,
+ required: false,
+ default: () => [],
},
},
computed: {
@@ -40,6 +64,12 @@ export default {
generatedSubheader() {
return Array.isArray(this.header) && this.header[1] ? generateText(this.header[1]) : '';
},
+ shouldShowHeaderActions() {
+ return Boolean(this.helpPopover) || this.actionButtons?.length > 0;
+ },
+ },
+ i18n: {
+ learnMore: __('Learn more'),
},
};
</script>
@@ -49,17 +79,46 @@ export default {
:class="{ 'gl-border-t gl-py-3 gl-pl-7': level === 2 }"
>
<status-icon v-if="statusIconName" :level="2" :name="widgetName" :icon-name="statusIconName" />
- <div>
- <slot name="header">
- <div v-if="header" class="gl-mb-2">
- <strong v-safe-html="generatedHeader" class="gl-display-block"></strong
- ><span
- v-if="generatedSubheader"
- v-safe-html="generatedSubheader"
- class="gl-display-block"
- ></span>
+ <div class="gl-w-full">
+ <div class="gl-display-flex">
+ <slot name="header">
+ <div v-if="header" class="gl-mb-2">
+ <strong v-safe-html="generatedHeader" class="gl-display-block"></strong
+ ><span
+ v-if="generatedSubheader"
+ v-safe-html="generatedSubheader"
+ class="gl-display-block"
+ ></span>
+ </div>
+ </slot>
+ <div
+ v-if="shouldShowHeaderActions"
+ class="gl-ml-auto gl-display-flex gl-align-items-baseline"
+ >
+ <help-popover v-if="helpPopover" :options="helpPopover.options">
+ <template v-if="helpPopover.content">
+ <p
+ v-if="helpPopover.content.text"
+ v-safe-html="helpPopover.content.text"
+ class="gl-mb-0"
+ ></p>
+ <gl-link
+ v-if="helpPopover.content.learnMorePath"
+ :href="helpPopover.content.learnMorePath"
+ target="_blank"
+ class="gl-font-sm"
+ >{{ $options.i18n.learnMore }}</gl-link
+ >
+ </template>
+ </help-popover>
+ <action-buttons
+ v-if="actionButtons.length > 0"
+ :widget="widgetName"
+ :tertiary-buttons="actionButtons"
+ :class="{ 'gl-ml-2': helpPopover }"
+ />
</div>
- </slot>
+ </div>
<div class="gl-display-flex gl-align-items-baseline gl-w-full">
<slot name="body"></slot>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index c6baf3b46ff..7109bed7743 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -192,4 +192,5 @@ export const DETAILED_MERGE_STATUS = {
POLICIES_DENIED: 'POLICIES_DENIED',
CI_MUST_PASS: 'CI_MUST_PASS',
CI_STILL_RUNNING: 'CI_STILL_RUNNING',
+ EXTERNAL_STATUS_CHECKS: 'EXTERNAL_STATUS_CHECKS',
};
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 a3f70b551bf..b96bdcb3833 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,7 +1,10 @@
<script>
import { GlSafeHtmlDirective } from '@gitlab/ui';
import { isEmpty } from 'lodash';
-import { registerExtension } from '~/vue_merge_request_widget/components/extensions';
+import {
+ registerExtension,
+ registeredExtensions,
+} from '~/vue_merge_request_widget/components/extensions';
import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue';
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';
@@ -47,6 +50,7 @@ import terraformExtension from './extensions/terraform';
import accessibilityExtension from './extensions/accessibility';
import codeQualityExtension from './extensions/code_quality';
import testReportExtension from './extensions/test_report';
+import ReportWidgetContainer from './components/report_widget_container.vue';
export default {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
@@ -82,21 +86,18 @@ export default {
MrWidgetAutoMergeFailed: AutoMergeFailed,
MrWidgetRebase: RebaseState,
SourceBranchRemovalStatus,
- GroupedCodequalityReportsApp: () =>
- import('../reports/codequality_report/grouped_codequality_reports_app.vue'),
- GroupedTestReportsApp: () =>
- import('../reports/grouped_test_report/grouped_test_reports_app.vue'),
MrWidgetApprovals,
SecurityReportsApp: () => import('~/vue_shared/security_reports/security_reports_app.vue'),
MergeChecksFailed: () => import('./components/states/merge_checks_failed.vue'),
ReadyToMerge: ReadyToMergeState,
+ ReportWidgetContainer,
},
apollo: {
state: {
query: getStateQuery,
manual: true,
skip() {
- return !this.mr || !window.gon?.features?.mergeRequestWidgetGraphql;
+ return !this.mr;
},
variables() {
return this.mergeRequestQueryVariables;
@@ -130,13 +131,6 @@ export default {
};
},
computed: {
- isLoaded() {
- if (window.gon?.features?.mergeRequestWidgetGraphql) {
- return !this.loading;
- }
-
- return this.mr;
- },
shouldRenderApprovals() {
return this.mr.state !== 'nothingToMerge';
},
@@ -185,9 +179,6 @@ export default {
shouldRenderTestReport() {
return Boolean(this.mr?.testResultsPath);
},
- shouldRenderRefactoredTestReport() {
- return window.gon?.features?.refactorMrWidgetTestSummary;
- },
mergeError() {
let { mergeError } = this.mr;
@@ -218,14 +209,14 @@ export default {
shouldShowSecurityExtension() {
return window.gon?.features?.refactorSecurityExtension;
},
- shouldShowCodeQualityExtension() {
- return window.gon?.features?.refactorCodeQualityExtension;
- },
shouldShowMergeDetails() {
if (this.mr.state === 'readyToMerge') return true;
return !this.mr.mergeDetailsCollapsed;
},
+ hasExtensions() {
+ return registeredExtensions.extensions.length;
+ },
},
watch: {
'mr.machineValue': {
@@ -343,9 +334,7 @@ export default {
return new MRWidgetService(this.getServiceEndpoints(store));
},
checkStatus(cb, isRebased) {
- if (window.gon?.features?.mergeRequestWidgetGraphql) {
- this.$apollo.queries.state.refetch();
- }
+ this.$apollo.queries.state.refetch();
return this.service
.checkStatus()
@@ -519,12 +508,12 @@ export default {
}
},
registerCodeQualityExtension() {
- if (this.shouldRenderCodeQuality && this.shouldShowCodeQualityExtension) {
+ if (this.shouldRenderCodeQuality) {
registerExtension(codeQualityExtension);
}
},
registerTestReportExtension() {
- if (this.shouldRenderTestReport && this.shouldRenderRefactoredTestReport) {
+ if (this.shouldRenderTestReport) {
registerExtension(testReportExtension);
}
},
@@ -532,7 +521,7 @@ export default {
};
</script>
<template>
- <div v-if="isLoaded" class="mr-state-widget gl-mt-3">
+ <div v-if="!loading" class="mr-state-widget gl-mt-3">
<header
v-if="shouldRenderCollaborationStatus"
class="gl-rounded-base gl-border-solid gl-border-1 gl-border-gray-100 gl-overflow-hidden mr-widget-workflow gl-mt-0!"
@@ -552,17 +541,19 @@ export default {
:user-callout-feature-id="mr.suggestPipelineFeatureId"
@dismiss="dismissSuggestPipelines"
/>
- <mr-widget-pipeline-container
- v-if="shouldRenderPipelines"
- class="mr-widget-workflow"
- :mr="mr"
- />
- <mr-widget-approvals
- v-if="shouldRenderApprovals"
- class="mr-widget-workflow"
- :mr="mr"
- :service="service"
- />
+ <mr-widget-pipeline-container v-if="shouldRenderPipelines" :mr="mr" />
+ <mr-widget-approvals v-if="shouldRenderApprovals" :mr="mr" :service="service" />
+ <report-widget-container>
+ <extensions-container v-if="hasExtensions" :mr="mr" />
+ <security-reports-app
+ v-if="shouldRenderSecurityReport && !shouldShowSecurityExtension"
+ :pipeline-id="mr.pipeline.id"
+ :project-id="mr.sourceProjectId"
+ :security-reports-docs-path="mr.securityReportsDocsPath"
+ :target-project-full-path="mr.targetProjectFullPath"
+ :mr-iid="mr.iid"
+ />
+ </report-widget-container>
<div class="mr-section-container mr-widget-workflow">
<div v-if="hasAlerts" class="gl-overflow-hidden mr-widget-alert-container">
<mr-widget-alert-message
@@ -589,35 +580,8 @@ export default {
</mr-widget-alert-message>
</div>
- <extensions-container :mr="mr" />
-
<widget-container v-if="mr" :mr="mr" />
- <grouped-codequality-reports-app
- v-if="shouldRenderCodeQuality && !shouldShowCodeQualityExtension"
- :head-blob-path="mr.headBlobPath"
- :base-blob-path="mr.baseBlobPath"
- :codequality-reports-path="mr.codequalityReportsPath"
- :codequality-help-path="mr.codequalityHelpPath"
- />
-
- <security-reports-app
- v-if="shouldRenderSecurityReport && !shouldShowSecurityExtension"
- :pipeline-id="mr.pipeline.id"
- :project-id="mr.sourceProjectId"
- :security-reports-docs-path="mr.securityReportsDocsPath"
- :target-project-full-path="mr.targetProjectFullPath"
- :mr-iid="mr.iid"
- />
-
- <grouped-test-reports-app
- v-if="shouldRenderTestReport && !shouldRenderRefactoredTestReport"
- class="js-reports-container"
- :endpoint="mr.testResultsPath"
- :head-blob-path="mr.headBlobPath"
- :pipeline-path="mr.pipeline.path"
- />
-
<div class="mr-widget-section" data-qa-selector="mr_widget_content">
<component :is="componentName" :mr="mr" :service="service" />
<ready-to-merge
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 731d3886f61..86ce032ea3d 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
@@ -29,6 +29,7 @@ export default class MergeRequestStore {
this.stateMachine = machine(STATE_MACHINE.definition);
this.machineValue = this.stateMachine.value;
this.mergeDetailsCollapsed = window.innerWidth < 768;
+ this.mergeError = data.mergeError;
this.setPaths(data);
@@ -157,25 +158,6 @@ export default class MergeRequestStore {
this.mergeCommitPath = data.merged_commit_path;
this.canPushToSourceBranch = data.can_push_to_source_branch;
- if (!window.gon?.features?.mergeRequestWidgetGraphql) {
- this.autoMergeEnabled = Boolean(data.auto_merge_enabled);
- this.canBeMerged = data.can_be_merged || false;
- this.canMerge = Boolean(data.merge_path);
- this.commitsCount = data.commits_count;
- this.branchMissing = data.branch_missing;
- this.hasConflicts = data.has_conflicts;
- this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false;
- this.isPipelineFailed = this.ciStatus === 'failed' || this.ciStatus === 'canceled';
- this.mergeError = data.merge_error;
- this.mergeStatus = data.merge_status;
- this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false;
- this.allowMergeOnSkippedPipeline = data.allow_merge_on_skipped_pipeline || false;
- this.projectArchived = data.project_archived;
- this.isSHAMismatch = this.sha !== data.diff_head_sha;
- this.shouldBeRebased = Boolean(data.should_be_rebased);
- this.draft = data.draft;
- }
-
const currentUser = data.current_user;
this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path;
@@ -299,7 +281,6 @@ export default class MergeRequestStore {
this.headBlobPath = blobPath.head_path || '';
this.baseBlobPath = blobPath.base_path || '';
this.codequalityReportsPath = data.codequality_reports_path;
- this.codequalityHelpPath = data.codequality_help_path;
// Security reports
this.sastComparisonPath = data.sast_comparison_path;
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
index f2ea55df63d..96c2ffa929c 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
@@ -145,11 +145,14 @@ export default {
},
currentTabIndex: {
get() {
- return this.$options.tabsConfig.findIndex((tab) => tab.id === this.activeTab);
+ const tabIndex = this.$options.tabsConfig.findIndex((tab) => tab.id === this.activeTab);
+ return tabIndex >= 0 ? tabIndex : 0;
},
set(tabIdx) {
const tabId = this.$options.tabsConfig[tabIdx].id;
- this.$router.replace({ name: 'tab', params: { tabId } });
+ if (this.$route.params?.tabId !== tabId) {
+ this.$router.push({ name: 'tab', params: { tabId } });
+ }
},
},
environmentName() {
diff --git a/app/assets/javascripts/vue_shared/alert_details/index.js b/app/assets/javascripts/vue_shared/alert_details/index.js
index 5793069440c..357dfa49901 100644
--- a/app/assets/javascripts/vue_shared/alert_details/index.js
+++ b/app/assets/javascripts/vue_shared/alert_details/index.js
@@ -15,9 +15,17 @@ Vue.use(VueApollo);
export default (selector) => {
const domEl = document.querySelector(selector);
- const { alertId, projectPath, projectIssuesPath, projectId, page, canUpdate } = domEl.dataset;
+ const {
+ alertId,
+ projectPath,
+ projectIssuesPath,
+ projectAlertManagementDetailsPath,
+ projectId,
+ page,
+ canUpdate,
+ } = domEl.dataset;
const iid = alertId;
- const router = createRouter();
+ const router = createRouter(projectAlertManagementDetailsPath);
const resolvers = {
Mutation: {
diff --git a/app/assets/javascripts/vue_shared/alert_details/router.js b/app/assets/javascripts/vue_shared/alert_details/router.js
index 5687fe4e0f5..26477a3a66a 100644
--- a/app/assets/javascripts/vue_shared/alert_details/router.js
+++ b/app/assets/javascripts/vue_shared/alert_details/router.js
@@ -5,9 +5,26 @@ import { joinPaths } from '~/lib/utils/url_utility';
Vue.use(VueRouter);
export default function createRouter(base) {
- return new VueRouter({
- mode: 'hash',
+ const router = 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/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
index dc4d1bd56e9..ed0eb9cc0b8 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
@@ -15,8 +15,14 @@ export default {
mounted() {
handleBlobRichViewer(this.$refs.content, this.type);
},
+ safeHtmlConfig: {
+ ADD_TAGS: ['copy-code'],
+ },
};
</script>
<template>
- <markdown-field-view ref="content" v-safe-html="richViewer || content" />
+ <markdown-field-view
+ ref="content"
+ v-safe-html:[$options.safeHtmlConfig]="richViewer || content"
+ />
</template>
diff --git a/app/assets/javascripts/vue_shared/components/code_block.stories.js b/app/assets/javascripts/vue_shared/components/code_block.stories.js
index e02a346c1de..994913dc1a8 100644
--- a/app/assets/javascripts/vue_shared/components/code_block.stories.js
+++ b/app/assets/javascripts/vue_shared/components/code_block.stories.js
@@ -13,6 +13,5 @@ const Template = (args, { argTypes }) => ({
export const Default = Template.bind({});
Default.args = {
- // eslint-disable-next-line @gitlab/require-i18n-strings
code: `git commit -a "Message"\ngit push`,
};
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue
index 5b9efff1c06..2bdc8a174d0 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue
@@ -36,6 +36,11 @@ export default {
required: false,
default: 'confirm-danger-button',
},
+ buttonQaSelector: {
+ type: String,
+ required: false,
+ default: null,
+ },
buttonVariant: {
type: String,
required: false,
@@ -53,7 +58,7 @@ export default {
:variant="buttonVariant"
:disabled="disabled"
:data-testid="buttonTestid"
- data-qa-selector="confirm_danger_button"
+ :data-qa-selector="buttonQaSelector"
>{{ buttonText }}</gl-button
>
<confirm-danger-modal
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js
index 7ecc309db52..b56434f746e 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js
@@ -1,4 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import ConfirmDanger from './confirm_danger.vue';
export default {
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js
index 8256d953466..a48b8bcfa8e 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js
@@ -1,5 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-
import { __ } from '~/locale';
import DropdownWidget from './dropdown_widget.vue';
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index 2227047a909..8a3a174f414 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -45,7 +45,7 @@ export default {
},
levelIndentation() {
return {
- marginLeft: this.level ? `${this.level * 16}px` : null,
+ marginLeft: this.level ? `${this.level * 8}px` : null,
};
},
fileClass() {
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
index 4873996d357..755ce004aa9 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -13,11 +13,19 @@ export const FILTER_NONE_ANY = [FILTER_NONE, FILTER_ANY];
export const OPERATOR_IS = '=';
export const OPERATOR_IS_TEXT = __('is');
export const OPERATOR_IS_NOT = '!=';
-export const OPERATOR_IS_NOT_TEXT = __('is not');
+export const OPERATOR_IS_NOT_TEXT = __('is not one of');
+export const OPERATOR_OR = '||';
+export const OPERATOR_OR_TEXT = __('is one of');
export const OPERATOR_IS_ONLY = [{ value: OPERATOR_IS, description: OPERATOR_IS_TEXT }];
export const OPERATOR_IS_NOT_ONLY = [{ value: OPERATOR_IS_NOT, description: OPERATOR_IS_NOT_TEXT }];
+export const OPERATOR_OR_ONLY = [{ value: OPERATOR_OR, description: OPERATOR_OR_TEXT }];
export const OPERATOR_IS_AND_IS_NOT = [...OPERATOR_IS_ONLY, ...OPERATOR_IS_NOT_ONLY];
+export const OPERATOR_IS_NOT_OR = [
+ ...OPERATOR_IS_ONLY,
+ ...OPERATOR_IS_NOT_ONLY,
+ ...OPERATOR_OR_ONLY,
+];
export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __('None'), title: __('None') };
export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') };
@@ -55,10 +63,26 @@ export const TOKEN_TITLE_MILESTONE = __('Milestone');
export const TOKEN_TITLE_MY_REACTION = __('My-Reaction');
export const TOKEN_TITLE_ORGANIZATION = s__('Crm|Organization');
export const TOKEN_TITLE_RELEASE = __('Release');
+export const TOKEN_TITLE_SOURCE_BRANCH = __('Source Branch');
+export const TOKEN_TITLE_STATUS = __('Status');
+export const TOKEN_TITLE_TARGET_BRANCH = __('Target Branch');
export const TOKEN_TITLE_TYPE = __('Type');
+export const TOKEN_TYPE_ASSIGNEE = 'assignee';
+export const TOKEN_TYPE_AUTHOR = 'author';
+export const TOKEN_TYPE_CONFIDENTIAL = 'confidential';
+export const TOKEN_TYPE_CONTACT = 'contact';
+export const TOKEN_TYPE_EPIC = 'epic';
// As health status gets reused between issue lists and boards
// this is in the shared constants. Until we have not decoupled the EE filtered search bar
// from the CE component, we need to keep this in the CE code.
// https://gitlab.com/gitlab-org/gitlab/-/issues/377838
-export const TOKEN_TYPE_HEALTH = 'health_status';
+export const TOKEN_TYPE_HEALTH = 'health';
+export const TOKEN_TYPE_ITERATION = 'iteration';
+export const TOKEN_TYPE_LABEL = 'label';
+export const TOKEN_TYPE_MILESTONE = 'milestone';
+export const TOKEN_TYPE_MY_REACTION = 'my-reaction';
+export const TOKEN_TYPE_ORGANIZATION = 'organization';
+export const TOKEN_TYPE_RELEASE = 'release';
+export const TOKEN_TYPE_TYPE = 'type';
+export const TOKEN_TYPE_WEIGHT = 'weight';
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 8821084ef35..0d0787e7033 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
@@ -89,6 +89,11 @@ export default {
required: false,
default: () => ({}),
},
+ showFriendlyText: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
syncFilterAndSort: {
type: Boolean,
required: false,
@@ -319,7 +324,7 @@ export default {
(sortBy) =>
sortBy.sortDirection.ascending === sort || sortBy.sortDirection.descending === sort,
);
- this.selectedSortDirection = Object.keys(this.selectedSortOption.sortDirection).find(
+ this.selectedSortDirection = Object.keys(this.selectedSortOption?.sortDirection || {}).find(
(key) => this.selectedSortOption.sortDirection[key] === sort,
);
},
@@ -351,6 +356,7 @@ export default {
:close-button-title="__('Close')"
:clear-recent-searches-text="__('Clear recent searches')"
:no-recent-searches-text="__(`You don't have any recent searches`)"
+ :show-friendly-text="showFriendlyText"
class="flex-grow-1"
@history-item-selected="handleHistoryItemSelected"
@clear="onClear"
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 482a2964b4c..2f10e068542 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
@@ -129,6 +129,8 @@ export default {
v-gl-tooltip.hover="toggleVisibilityLabel"
:aria-label="toggleVisibilityLabel"
:icon="toggleVisibilityIcon"
+ data-testid="toggle-visibility-button"
+ data-qa-selector="toggle_visibility_button"
@click.stop="handleToggleVisibilityButtonClick"
/>
<clipboard-button
diff --git a/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue b/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue
deleted file mode 100644
index c2be5e4f7a1..00000000000
--- a/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue
+++ /dev/null
@@ -1,89 +0,0 @@
-<script>
-import { GlBadge } from '@gitlab/ui';
-import { s__ } from '~/locale';
-import Tracking from '~/tracking';
-import axios from '~/lib/utils/axios_utils';
-import { joinPaths } from '~/lib/utils/url_utility';
-import { helpPagePath } from '~/helpers/help_page_helper';
-
-const STATUS_TYPES = {
- SUCCESS: 'success',
- WARNING: 'warning',
- DANGER: 'danger',
-};
-
-const UPGRADE_DOCS_URL = helpPagePath('update/index');
-
-export default {
- name: 'GitlabVersionCheck',
- components: {
- GlBadge,
- },
- mixins: [Tracking.mixin()],
- props: {
- size: {
- type: String,
- required: false,
- default: 'md',
- },
- },
- data() {
- return {
- status: null,
- };
- },
- computed: {
- title() {
- if (this.status === STATUS_TYPES.SUCCESS) {
- return s__('VersionCheck|Up to date');
- } else if (this.status === STATUS_TYPES.WARNING) {
- return s__('VersionCheck|Update available');
- } else if (this.status === STATUS_TYPES.DANGER) {
- return s__('VersionCheck|Update ASAP');
- }
-
- return null;
- },
- },
- created() {
- this.checkGitlabVersion();
- },
- methods: {
- checkGitlabVersion() {
- axios
- .get(joinPaths('/', gon.relative_url_root, '/admin/version_check.json'))
- .then((res) => {
- if (res.data) {
- this.status = res.data.severity;
-
- this.track('rendered_version_badge', {
- label: this.title,
- });
- }
- })
- .catch(() => {
- // Silently fail
- this.status = null;
- });
- },
- onClick() {
- this.track('click_version_badge', { label: this.title });
- },
- },
- UPGRADE_DOCS_URL,
-};
-</script>
-
-<template>
- <!-- TODO: remove the span element once bootstrap-vue is updated to version 2.21.1 -->
- <!-- TODO: https://github.com/bootstrap-vue/bootstrap-vue/issues/6219 -->
- <span v-if="status" data-testid="badge-click-wrapper" @click="onClick">
- <gl-badge
- :href="$options.UPGRADE_DOCS_URL"
- class="version-check-badge"
- :variant="status"
- :size="size"
- >{{ title }}</gl-badge
- >
- </span>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/group_select/constants.js b/app/assets/javascripts/vue_shared/components/group_select/constants.js
new file mode 100644
index 00000000000..bc70936eb36
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/group_select/constants.js
@@ -0,0 +1,6 @@
+import { __ } from '~/locale';
+
+export const TOGGLE_TEXT = __('Search for a group');
+export const FETCH_GROUPS_ERROR = __('Unable to fetch groups. Reload the page to try again.');
+export const FETCH_GROUP_ERROR = __('Unable to fetch group. Reload the page to try again.');
+export const QUERY_TOO_SHORT_MESSAGE = __('Enter at least three characters to search.');
diff --git a/app/assets/javascripts/vue_shared/components/group_select/group_select.vue b/app/assets/javascripts/vue_shared/components/group_select/group_select.vue
new file mode 100644
index 00000000000..1de6c0121bc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/group_select/group_select.vue
@@ -0,0 +1,195 @@
+<script>
+import { debounce } from 'lodash';
+import { GlListbox } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import Api from '~/api';
+import { __ } from '~/locale';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { createAlert } from '~/flash';
+import { groupsPath } from './utils';
+import {
+ TOGGLE_TEXT,
+ FETCH_GROUPS_ERROR,
+ FETCH_GROUP_ERROR,
+ QUERY_TOO_SHORT_MESSAGE,
+} from './constants';
+
+const MINIMUM_QUERY_LENGTH = 3;
+
+export default {
+ components: {
+ GlListbox,
+ },
+ props: {
+ inputName: {
+ type: String,
+ required: true,
+ },
+ inputId: {
+ type: String,
+ required: true,
+ },
+ initialSelection: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ clearable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ parentGroupID: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ groupsFilter: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ pristine: true,
+ searching: false,
+ searchString: '',
+ groups: [],
+ selectedValue: null,
+ selectedText: null,
+ };
+ },
+ computed: {
+ selected: {
+ set(value) {
+ this.selectedValue = value;
+ this.selectedText =
+ value === null ? null : this.groups.find((group) => group.value === value).full_name;
+ },
+ get() {
+ return this.selectedValue;
+ },
+ },
+ toggleText() {
+ return this.selectedText ?? this.$options.i18n.toggleText;
+ },
+ inputValue() {
+ return this.selectedValue ? this.selectedValue : '';
+ },
+ isSearchQueryTooShort() {
+ return this.searchString && this.searchString.length < MINIMUM_QUERY_LENGTH;
+ },
+ noResultsText() {
+ return this.isSearchQueryTooShort
+ ? this.$options.i18n.searchQueryTooShort
+ : this.$options.i18n.noResultsText;
+ },
+ },
+ created() {
+ this.fetchInitialSelection();
+ },
+ methods: {
+ search: debounce(function debouncedSearch(searchString) {
+ this.searchString = searchString;
+ if (this.isSearchQueryTooShort) {
+ this.groups = [];
+ } else {
+ this.fetchGroups(searchString);
+ }
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
+ async fetchGroups(searchString = '') {
+ this.searching = true;
+
+ try {
+ const { data } = await axios.get(
+ Api.buildUrl(groupsPath(this.groupsFilter, this.parentGroupID)),
+ {
+ params: {
+ search: searchString,
+ },
+ },
+ );
+ const groups = data.length ? data : data.results || [];
+
+ this.groups = groups.map((group) => ({
+ ...group,
+ value: String(group.id),
+ }));
+
+ this.searching = false;
+ } catch (error) {
+ createAlert({
+ message: FETCH_GROUPS_ERROR,
+ error,
+ parent: this.$el,
+ });
+ }
+ },
+ async fetchInitialSelection() {
+ if (!this.initialSelection) {
+ this.pristine = false;
+ return;
+ }
+ this.searching = true;
+ try {
+ const group = await Api.group(this.initialSelection);
+ this.selectedValue = this.initialSelection;
+ this.selectedText = group.full_name;
+ this.pristine = false;
+ this.searching = false;
+ } catch (error) {
+ createAlert({
+ message: FETCH_GROUP_ERROR,
+ error,
+ parent: this.$el,
+ });
+ }
+ },
+ onShown() {
+ if (!this.searchString && !this.groups.length) {
+ this.fetchGroups();
+ }
+ },
+ onReset() {
+ this.selected = null;
+ },
+ },
+ i18n: {
+ toggleText: TOGGLE_TEXT,
+ selectGroup: __('Select a group'),
+ reset: __('Reset'),
+ noResultsText: __('No results found.'),
+ searchQueryTooShort: QUERY_TOO_SHORT_MESSAGE,
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-listbox
+ ref="listbox"
+ v-model="selected"
+ :header-text="$options.i18n.selectGroup"
+ :reset-button-label="$options.i18n.reset"
+ :toggle-text="toggleText"
+ :loading="searching && pristine"
+ :searching="searching"
+ :items="groups"
+ :no-results-text="noResultsText"
+ searchable
+ @shown="onShown"
+ @search="search"
+ @reset="onReset"
+ >
+ <template #list-item="{ item }">
+ <div class="gl-font-weight-bold">
+ {{ item.full_name }}
+ </div>
+ <div class="gl-text-gray-300">{{ item.full_path }}</div>
+ </template>
+ </gl-listbox>
+ <div class="flash-container"></div>
+ <input :id="inputId" data-testid="input" type="hidden" :name="inputName" :value="inputValue" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue
index 1b89bd324c6..f349aa78bac 100644
--- a/app/assets/javascripts/vue_shared/components/help_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/help_popover.vue
@@ -20,6 +20,11 @@ export default {
required: false,
default: () => ({}),
},
+ icon: {
+ type: String,
+ required: false,
+ default: 'question-o',
+ },
},
methods: {
targetFn() {
@@ -30,7 +35,7 @@ export default {
</script>
<template>
<span>
- <gl-button ref="popoverTrigger" variant="link" icon="question-o" :aria-label="__('Help')" />
+ <gl-button ref="popoverTrigger" variant="link" :icon="icon" :aria-label="__('Help')" />
<gl-popover :target="targetFn" v-bind="options">
<template v-if="options.title" #title>
<span v-safe-html="options.title"></span>
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 b38772d5aa5..c0712e46613 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
@@ -72,7 +72,7 @@ export default {
required: false,
default: '',
},
- initOnAutofocus: {
+ autofocus: {
type: Boolean,
required: false,
default: false,
@@ -87,20 +87,20 @@ export default {
return {
editingMode: EDITING_MODE_MARKDOWN_FIELD,
switchEditingControlEnabled: true,
- autofocus: this.initOnAutofocus,
+ autofocused: false,
};
},
computed: {
isContentEditorActive() {
return this.enableContentEditor && this.editingMode === EDITING_MODE_CONTENT_EDITOR;
},
- contentEditorAutofocus() {
+ contentEditorAutofocused() {
// Match textarea focus behavior
- return this.autofocus ? 'end' : false;
+ return this.autofocus && !this.autofocused ? 'end' : false;
},
},
mounted() {
- this.autofocusTextarea(this.editingMode);
+ this.autofocusTextarea();
},
methods: {
updateMarkdownFromContentEditor({ markdown }) {
@@ -120,7 +120,6 @@ export default {
},
onEditingModeChange(editingMode) {
this.notifyEditingModeChange(editingMode);
- this.enableAutofocus(editingMode);
},
onEditingModeRestored(editingMode) {
this.notifyEditingModeChange(editingMode);
@@ -128,15 +127,15 @@ export default {
notifyEditingModeChange(editingMode) {
this.$emit(editingMode);
},
- enableAutofocus(editingMode) {
- this.autofocus = true;
- this.autofocusTextarea(editingMode);
- },
- autofocusTextarea(editingMode) {
- if (this.autofocus && editingMode === EDITING_MODE_MARKDOWN_FIELD) {
+ autofocusTextarea() {
+ if (this.autofocus && this.editingMode === EDITING_MODE_MARKDOWN_FIELD) {
this.$refs.textarea.focus();
+ this.setEditorAsAutofocused();
}
},
+ setEditorAsAutofocused() {
+ this.autofocused = true;
+ },
},
switchEditingControlOptions: [
{ text: __('Source'), value: EDITING_MODE_MARKDOWN_FIELD },
@@ -197,7 +196,8 @@ export default {
:render-markdown="renderMarkdown"
:uploads-path="uploadsPath"
:markdown="value"
- :autofocus="contentEditorAutofocus"
+ :autofocus="contentEditorAutofocused"
+ @initialized="setEditorAsAutofocused"
@change="updateMarkdownFromContentEditor"
@loading="disableSwitchEditingControl"
@loadingSuccess="enableSwitchEditingControl"
diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/makrdown_drawer.stories.js b/app/assets/javascripts/vue_shared/components/markdown_drawer/makrdown_drawer.stories.js
new file mode 100644
index 00000000000..03bd64e2a57
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/makrdown_drawer.stories.js
@@ -0,0 +1,54 @@
+import { GlButton } from '@gitlab/ui';
+import { MOCK_HTML } from '../../../../../../spec/frontend/vue_shared/components/markdown_drawer/mock_data';
+import MarkdownDrawer from './markdown_drawer.vue';
+
+export default {
+ component: MarkdownDrawer,
+ title: 'vue_shared/markdown_drawer',
+ parameters: {
+ mirage: {
+ timing: 1000,
+ handlers: {
+ get: {
+ '/help/user/search/global_search/advanced_search_syntax.json': [
+ 200,
+ {},
+ { html: MOCK_HTML },
+ ],
+ },
+ },
+ },
+ },
+};
+
+const createStory = ({ ...options }) => (_, { argTypes }) => ({
+ components: { MarkdownDrawer, GlButton },
+ props: Object.keys(argTypes),
+ data() {
+ return {
+ render: false,
+ };
+ },
+ methods: {
+ toggleDrawer() {
+ this.$refs.drawer.toggleDrawer();
+ },
+ },
+ mounted() {
+ window.requestAnimationFrame(() => {
+ this.render = true;
+ });
+ },
+ template: `
+ <div v-if="render">
+ <gl-button @click="toggleDrawer">Open Drawer</gl-button>
+ <markdown-drawer
+ :documentPath="'user/search/global_search/advanced_search_syntax.json'"
+ ref="drawer"
+ />
+ </div>
+ `,
+ ...options,
+});
+
+export const Default = createStory({});
diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue
new file mode 100644
index 00000000000..a4b509f8656
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue
@@ -0,0 +1,117 @@
+<script>
+import { GlSafeHtmlDirective as SafeHtml, GlDrawer, GlAlert, GlSkeletonLoader } from '@gitlab/ui';
+import $ from 'jquery';
+import '~/behaviors/markdown/render_gfm';
+import { s__ } from '~/locale';
+import { contentTop } from '~/lib/utils/common_utils';
+import { getRenderedMarkdown } from './utils/fetch';
+
+export const cache = {};
+
+export default {
+ name: 'MarkdownDrawer',
+ components: {
+ GlDrawer,
+ GlAlert,
+ GlSkeletonLoader,
+ },
+ directives: {
+ SafeHtml,
+ },
+ i18n: {
+ alert: s__('MardownDrawer|Could not fetch help contents.'),
+ },
+ props: {
+ documentPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ loading: false,
+ hasFetchError: false,
+ title: '',
+ body: null,
+ open: false,
+ };
+ },
+ computed: {
+ drawerOffsetTop() {
+ return `${contentTop()}px`;
+ },
+ },
+ watch: {
+ documentPath: {
+ immediate: true,
+ handler: 'fetchMarkdown',
+ },
+ open(open) {
+ if (open && this.body) {
+ this.renderGLFM();
+ }
+ },
+ },
+ methods: {
+ async fetchMarkdown() {
+ const cached = cache[this.documentPath];
+ this.hasFetchError = false;
+ this.title = '';
+ if (cached) {
+ this.title = cached.title;
+ this.body = cached.body;
+ if (this.open) {
+ this.renderGLFM();
+ }
+ } else {
+ this.loading = true;
+ const { body, title, hasFetchError } = await getRenderedMarkdown(this.documentPath);
+ this.title = title;
+ this.body = body;
+ this.loading = false;
+ this.hasFetchError = hasFetchError;
+ if (this.open) {
+ this.renderGLFM();
+ }
+ cache[this.documentPath] = { title, body };
+ }
+ },
+ renderGLFM() {
+ this.$nextTick(() => {
+ $(this.$refs['content-element']).renderGFM();
+ });
+ },
+ closeDrawer() {
+ this.open = false;
+ },
+ toggleDrawer() {
+ this.open = !this.open;
+ },
+ openDrawer() {
+ this.open = true;
+ },
+ },
+ safeHtmlConfig: {
+ ADD_TAGS: ['copy-code'],
+ },
+};
+</script>
+<template>
+ <gl-drawer :header-height="drawerOffsetTop" :open="open" header-sticky @close="closeDrawer">
+ <template #title>
+ <h4 data-testid="title-element" class="gl-m-0">{{ title }}</h4>
+ </template>
+ <template #default>
+ <div v-if="hasFetchError">
+ <gl-alert :dismissible="false" variant="danger">{{ $options.i18n.alert }}</gl-alert>
+ </div>
+ <gl-skeleton-loader v-else-if="loading" />
+ <div
+ v-else
+ ref="content-element"
+ v-safe-html:[$options.safeHtmlConfig]="body"
+ class="md"
+ ></div>
+ </template>
+ </gl-drawer>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js b/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js
new file mode 100644
index 00000000000..7c8e1bc160a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js
@@ -0,0 +1,32 @@
+import * as Sentry from '@sentry/browser';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import axios from '~/lib/utils/axios_utils';
+
+export const splitDocument = (htmlString) => {
+ const htmlDocument = new DOMParser().parseFromString(htmlString, 'text/html');
+ const title = htmlDocument.querySelector('h1')?.innerText;
+ htmlDocument.querySelector('h1')?.remove();
+ return {
+ title,
+ body: htmlDocument.querySelector('body').innerHTML.toString(),
+ };
+};
+
+export const getRenderedMarkdown = (documentPath) => {
+ return axios
+ .get(helpPagePath(documentPath))
+ .then(({ data }) => {
+ const { body, title } = splitDocument(data.html);
+ return {
+ body,
+ title,
+ hasFetchError: false,
+ };
+ })
+ .catch((e) => {
+ Sentry.captureException(e);
+ return {
+ hasFetchError: true,
+ };
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select_deprecated.vue b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select_deprecated.vue
deleted file mode 100644
index ba9edc7620a..00000000000
--- a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select_deprecated.vue
+++ /dev/null
@@ -1,212 +0,0 @@
-<script>
-import {
- GlDropdown,
- GlDropdownDivider,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlSearchBoxByType,
- GlIntersectionObserver,
- GlLoadingIcon,
-} from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export const EMPTY_NAMESPACE_ID = -1;
-export const i18n = {
- DEFAULT_TEXT: __('Select a new namespace'),
- DEFAULT_EMPTY_NAMESPACE_TEXT: __('No namespace'),
- GROUPS: __('Groups'),
- USERS: __('Users'),
-};
-
-const filterByName = (data, searchTerm = '') => {
- if (!searchTerm) {
- return data;
- }
-
- return data.filter((d) => d.humanName.toLowerCase().includes(searchTerm.toLowerCase()));
-};
-
-export default {
- name: 'NamespaceSelectDeprecated',
- components: {
- GlDropdown,
- GlDropdownDivider,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlSearchBoxByType,
- GlIntersectionObserver,
- GlLoadingIcon,
- },
- props: {
- groupNamespaces: {
- type: Array,
- required: false,
- default: () => [],
- },
- userNamespaces: {
- type: Array,
- required: false,
- default: () => [],
- },
- fullWidth: {
- type: Boolean,
- required: false,
- default: false,
- },
- defaultText: {
- type: String,
- required: false,
- default: i18n.DEFAULT_TEXT,
- },
- includeHeaders: {
- type: Boolean,
- required: false,
- default: true,
- },
- emptyNamespaceTitle: {
- type: String,
- required: false,
- default: i18n.DEFAULT_EMPTY_NAMESPACE_TEXT,
- },
- includeEmptyNamespace: {
- type: Boolean,
- required: false,
- default: false,
- },
- hasNextPageOfGroups: {
- type: Boolean,
- required: false,
- default: false,
- },
- isLoading: {
- type: Boolean,
- required: false,
- default: false,
- },
- isSearchLoading: {
- type: Boolean,
- required: false,
- default: false,
- },
- shouldFilterNamespaces: {
- type: Boolean,
- required: false,
- default: true,
- },
- },
- data() {
- return {
- searchTerm: '',
- selectedNamespace: null,
- };
- },
- computed: {
- hasUserNamespaces() {
- return this.userNamespaces.length;
- },
- hasGroupNamespaces() {
- return this.groupNamespaces.length;
- },
- filteredGroupNamespaces() {
- if (!this.shouldFilterNamespaces) return this.groupNamespaces;
- if (!this.hasGroupNamespaces) return [];
- return filterByName(this.groupNamespaces, this.searchTerm);
- },
- filteredUserNamespaces() {
- if (!this.shouldFilterNamespaces) return this.userNamespaces;
- if (!this.hasUserNamespaces) return [];
- return filterByName(this.userNamespaces, this.searchTerm);
- },
- selectedNamespaceText() {
- return this.selectedNamespace?.humanName || this.defaultText;
- },
- filteredEmptyNamespaceTitle() {
- const { includeEmptyNamespace, emptyNamespaceTitle, searchTerm } = this;
-
- if (!includeEmptyNamespace) {
- return '';
- }
- if (!searchTerm) {
- return emptyNamespaceTitle;
- }
-
- return emptyNamespaceTitle.toLowerCase().includes(searchTerm.toLowerCase());
- },
- },
- watch: {
- searchTerm() {
- this.$emit('search', this.searchTerm);
- },
- },
- methods: {
- handleSelect(item) {
- this.selectedNamespace = item;
- this.searchTerm = '';
- this.$emit('select', item);
- },
- handleSelectEmptyNamespace() {
- this.handleSelect({ id: EMPTY_NAMESPACE_ID, humanName: this.emptyNamespaceTitle });
- },
- },
- i18n,
-};
-</script>
-<template>
- <gl-dropdown
- :text="selectedNamespaceText"
- :block="fullWidth"
- data-qa-selector="namespaces_list"
- @show="$emit('show')"
- >
- <template #header>
- <gl-search-box-by-type
- v-model.trim="searchTerm"
- :is-loading="isSearchLoading"
- data-qa-selector="namespaces_list_search"
- />
- </template>
- <div v-if="filteredEmptyNamespaceTitle">
- <gl-dropdown-item
- data-qa-selector="namespaces_list_item"
- @click="handleSelectEmptyNamespace()"
- >
- {{ emptyNamespaceTitle }}
- </gl-dropdown-item>
- <gl-dropdown-divider />
- </div>
- <div
- v-if="hasUserNamespaces"
- data-qa-selector="namespaces_list_users"
- data-testid="namespace-list-users"
- >
- <gl-dropdown-section-header v-if="includeHeaders">{{
- $options.i18n.USERS
- }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="item in filteredUserNamespaces"
- :key="item.id"
- data-qa-selector="namespaces_list_item"
- @click="handleSelect(item)"
- >{{ item.humanName }}</gl-dropdown-item
- >
- </div>
- <div
- v-if="hasGroupNamespaces"
- data-qa-selector="namespaces_list_groups"
- data-testid="namespace-list-groups"
- >
- <gl-dropdown-section-header v-if="includeHeaders">{{
- $options.i18n.GROUPS
- }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="item in filteredGroupNamespaces"
- :key="item.id"
- data-qa-selector="namespaces_list_item"
- @click="handleSelect(item)"
- >{{ item.humanName }}</gl-dropdown-item
- >
- </div>
- <gl-loading-icon v-if="isLoading" class="gl-mb-3" size="sm" />
- <gl-intersection-observer v-if="hasNextPageOfGroups" @appear="$emit('load-more-groups')" />
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
index a5027d2ca5c..867222279b2 100644
--- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
+++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
@@ -9,9 +9,12 @@ import {
} from '@gitlab/ui';
import Api from '~/api';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
import Tracking from '~/tracking';
-import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ OPERATOR_IS_ONLY,
+ TOKEN_TITLE_ASSIGNEE,
+ TOKEN_TITLE_AUTHOR,
+} from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import { initialPaginationState, defaultI18n, defaultPageSize } from './constants';
@@ -112,7 +115,7 @@ export default {
{
type: 'author_username',
icon: 'user',
- title: __('Author'),
+ title: TOKEN_TITLE_AUTHOR,
unique: true,
symbol: '@',
token: AuthorToken,
@@ -123,7 +126,7 @@ export default {
{
type: 'assignee_username',
icon: 'user',
- title: __('Assignee'),
+ title: TOKEN_TITLE_ASSIGNEE,
unique: true,
symbol: '@',
token: AuthorToken,
diff --git a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js
index f16afc77164..fd9d69bae22 100644
--- a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js
+++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js
@@ -1,4 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import PaginationBar from './pagination_bar.vue';
export default {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js
deleted file mode 100644
index 1c08433ee78..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js
+++ /dev/null
@@ -1 +0,0 @@
-// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports.
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js
deleted file mode 100644
index 1c08433ee78..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js
+++ /dev/null
@@ -1 +0,0 @@
-// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports.
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
index 0f5560ff628..02323e5a0c6 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
@@ -43,6 +43,11 @@ export default {
required: false,
default: false,
},
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -128,7 +133,7 @@ export default {
</script>
<template>
- <div class="block js-issuable-move-block issuable-move-dropdown sidebar-move-issue-dropdown">
+ <div class="js-issuable-move-block issuable-move-dropdown sidebar-move-issue-dropdown">
<div
v-gl-tooltip.left.viewport
data-testid="move-collapsed"
@@ -141,7 +146,7 @@ export default {
<gl-dropdown
ref="dropdown"
:block="true"
- :disabled="moveInProgress"
+ :disabled="moveInProgress || disabled"
class="hide-collapsed"
toggle-class="js-sidebar-dropdown-toggle"
@shown="fetchProjects"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js
deleted file mode 100644
index 1c08433ee78..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js
+++ /dev/null
@@ -1 +0,0 @@
-// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports.
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
index 0127df730b8..27186281c42 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
@@ -194,6 +194,7 @@ export default {
ref="dropdown"
:text="buttonText"
class="gl-w-full"
+ block
data-testid="labels-select-dropdown-contents"
data-qa-selector="labels_dropdown_content"
@hide="handleDropdownHide"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue
index caeee2df7e5..314ffbaf84c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue
@@ -10,7 +10,7 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-align-items-center">
+ <div class="gl-display-flex gl-align-items-center 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/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
index 0e8da7281d8..2c27a69d587 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
@@ -129,9 +129,6 @@ export default {
issuableId() {
return this.issuable?.id;
},
- isRealtimeEnabled() {
- return this.glFeatures.realtimeLabels;
- },
},
apollo: {
issuable: {
@@ -163,7 +160,7 @@ export default {
};
},
skip() {
- return !this.issuableId || !this.isDropdownVariantSidebar || !this.isRealtimeEnabled;
+ return !this.issuableId || !this.isDropdownVariantSidebar;
},
updateQuery(
_,
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql
new file mode 100644
index 00000000000..a1b16b378b3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql
@@ -0,0 +1,22 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+subscription mergeRequestReviewersUpdated($issuableId: IssuableID!) {
+ mergeRequestReviewersUpdated(issuableId: $issuableId) {
+ ... on MergeRequest {
+ id
+ reviewers {
+ nodes {
+ ...User
+ ...UserAvailability
+ mergeRequestInteraction {
+ canMerge
+ canUpdate
+ approved
+ reviewed
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
index 8a2bab4cb9a..465ee9aa0d4 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
@@ -1,5 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-
import TodoButton from './todo_button.vue';
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 9683288f937..a2d8b7cbd15 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,5 +1,6 @@
<script>
import { GlIntersectionObserver, GlSafeHtmlDirective } from '@gitlab/ui';
+import { scrollToElement } from '~/lib/utils/common_utils';
import ChunkLine from './chunk_line.vue';
/*
@@ -23,6 +24,11 @@ export default {
SafeHtml: GlSafeHtmlDirective,
},
props: {
+ isFirstChunk: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
chunkIndex: {
type: Number,
required: false,
@@ -46,6 +52,11 @@ export default {
required: false,
default: 0,
},
+ totalChunks: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
language: {
type: String,
required: false,
@@ -56,53 +67,68 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ isLoading: true,
+ };
+ },
computed: {
lines() {
return this.content.split('\n');
},
},
+
+ created() {
+ if (this.isFirstChunk) {
+ this.isLoading = false;
+ return;
+ }
+
+ window.requestIdleCallback(() => {
+ this.isLoading = false;
+ const { hash } = this.$route;
+ if (hash && this.totalChunks > 0 && this.totalChunks === this.chunkIndex + 1) {
+ // when the last chunk is loaded scroll to the hash
+ scrollToElement(hash, { behavior: 'auto' });
+ }
+ });
+ },
methods: {
handleChunkAppear() {
if (!this.isHighlighted) {
this.$emit('appear', this.chunkIndex);
}
},
+ calculateLineNumber(index) {
+ return this.startingFrom + index + 1;
+ },
},
};
</script>
<template>
- <div>
- <gl-intersection-observer @appear="handleChunkAppear">
- <div v-if="isHighlighted">
- <chunk-line
- v-for="(line, index) in lines"
+ <gl-intersection-observer @appear="handleChunkAppear">
+ <div v-if="isHighlighted">
+ <chunk-line
+ v-for="(line, index) in lines"
+ :key="index"
+ :number="calculateLineNumber(index)"
+ :content="line"
+ :language="language"
+ :blame-path="blamePath"
+ />
+ </div>
+ <div v-else-if="!isLoading" class="gl-display-flex gl-text-transparent">
+ <div class="gl-display-flex gl-flex-direction-column content-visibility-auto">
+ <span
+ v-for="(n, index) in totalLines"
+ v-once
+ :id="`L${calculateLineNumber(index)}`"
:key="index"
- :number="startingFrom + index + 1"
- :content="line"
- :language="language"
- :blame-path="blamePath"
- />
- </div>
- <div v-else class="gl-display-flex">
- <div class="gl-display-flex gl-flex-direction-column">
- <a
- v-for="(n, index) in totalLines"
- :id="`L${startingFrom + index + 1}`"
- :key="index"
- class="gl-ml-5 gl-text-transparent"
- :href="`#L${startingFrom + index + 1}`"
- :data-line-number="startingFrom + index + 1"
- data-testid="line-number"
- >
- {{ startingFrom + index + 1 }}
- </a>
- </div>
- <div
- class="gl-white-space-pre-wrap! gl-text-transparent"
- data-testid="content"
- v-text="content"
- ></div>
+ data-testid="line-number"
+ v-text="calculateLineNumber(index)"
+ ></span>
</div>
- </gl-intersection-observer>
- </div>
+ <div v-once class="gl-white-space-pre-wrap!" data-testid="content">{{ content }}</div>
+ </div>
+ </gl-intersection-observer>
</template>
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 ffd0eea63a1..0bf19f83d86 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,6 +1,7 @@
<script>
import { GlSafeHtmlDirective } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { getPageParamValue, getPageSearchString } from '~/blob/utils';
export default {
directives: {
@@ -25,6 +26,13 @@ export default {
required: true,
},
},
+ computed: {
+ pageSearchString() {
+ if (!this.glFeatures.fileLineBlame) return '';
+ const page = getPageParamValue(this.number);
+ return getPageSearchString(this.blamePath, page);
+ },
+ },
};
</script>
<template>
@@ -35,7 +43,7 @@ export default {
<a
v-if="glFeatures.fileLineBlame"
class="gl-user-select-none gl-shadow-none! file-line-blame"
- :href="`${blamePath}#L${number}`"
+ :href="`${blamePath}${pageSearchString}#L${number}`"
></a>
<a
:id="`L${number}`"
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js
index d957990fe7f..fca2616f069 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js
@@ -1,9 +1,17 @@
import packageJsonLinker from './utils/package_json_linker';
import gemspecLinker from './utils/gemspec_linker';
+import godepsJsonLinker from './utils/godeps_json_linker';
+import gemfileLinker from './utils/gemfile_linker';
+import podspecJsonLinker from './utils/podspec_json_linker';
+import composerJsonLinker from './utils/composer_json_linker';
const DEPENDENCY_LINKERS = {
package_json: packageJsonLinker,
gemspec: gemspecLinker,
+ godeps_json: godepsJsonLinker,
+ gemfile: gemfileLinker,
+ podspec_json: podspecJsonLinker,
+ composer_json: composerJsonLinker,
};
/**
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/composer_json_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/composer_json_linker.js
new file mode 100644
index 00000000000..f5c4c886546
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/composer_json_linker.js
@@ -0,0 +1,49 @@
+import { createLink, generateHLJSOpenTag } from './dependency_linker_util';
+
+const PACKAGIST_URL = 'https://packagist.org/packages/';
+const DRUPAL_URL = 'https://www.drupal.org/project/';
+
+const attrOpenTag = generateHLJSOpenTag('attr');
+const stringOpenTag = generateHLJSOpenTag('string');
+const closeTag = '&quot;</span>';
+const DRUPAL_PROJECT_SEPARATOR = 'drupal/';
+const DEPENDENCY_REGEX = new RegExp(
+ /*
+ * Detects dependencies inside of content that is highlighted by Highlight.js
+ * Example: <span class="hljs-attr">&quot;composer/installers&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;^1.2&quot;</span>
+ * Group 1: composer/installers
+ * Group 2: ^1.2
+ */
+ `${attrOpenTag}([^/]+/[^/]+.)${closeTag}.*${stringOpenTag}(.*[0-9].*)(${closeTag})`,
+ 'gm',
+);
+
+const handleReplace = (original, packageName, version, dependenciesToLink) => {
+ const isDrupalDependency = packageName.includes(DRUPAL_PROJECT_SEPARATOR);
+ const href = isDrupalDependency
+ ? `${DRUPAL_URL}${packageName.split(DRUPAL_PROJECT_SEPARATOR)[1]}`
+ : `${PACKAGIST_URL}${packageName}`;
+ const packageLink = createLink(href, packageName);
+ const versionLink = createLink(href, version);
+ const closeAndOpenTag = `${closeTag}: ${attrOpenTag}`;
+ const dependencyToLink = dependenciesToLink[packageName];
+
+ if (dependencyToLink && dependencyToLink === version) {
+ return `${attrOpenTag}${packageLink}${closeAndOpenTag}${versionLink}${closeTag}`;
+ }
+
+ return original;
+};
+
+export default (result, raw) => {
+ const rawParsed = JSON.parse(raw);
+
+ const dependenciesToLink = {
+ ...rawParsed.require,
+ ...rawParsed['require-dev'],
+ };
+
+ return result.value.replace(DEPENDENCY_REGEX, (original, packageName, version) =>
+ handleReplace(original, packageName, version, dependenciesToLink),
+ );
+};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js
index 49704421d6e..c1a1101afad 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js
@@ -1,7 +1,27 @@
import { escape } from 'lodash';
export const createLink = (href, innerText) =>
- `<a href="${escape(href)}" rel="nofollow noreferrer noopener">${escape(innerText)}</a>`;
+ `<a href="${escape(href)}" target="_blank" rel="nofollow noreferrer noopener">${escape(
+ innerText,
+ )}</a>`;
export const generateHLJSOpenTag = (type, delimiter = '&quot;') =>
`<span class="hljs-${escape(type)}">${delimiter}`;
+
+export const getObjectKeysByKeyName = (obj, keyName, acc) => {
+ if (obj instanceof Array) {
+ obj.map((subObj) => getObjectKeysByKeyName(subObj, keyName, acc));
+ } else {
+ for (const key in obj) {
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
+ if (key === keyName) {
+ acc.push(...Object.keys(obj[key]));
+ }
+ if (obj[key] instanceof Object || obj[key] instanceof Array) {
+ getObjectKeysByKeyName(obj[key], keyName, acc);
+ }
+ }
+ }
+ }
+ return acc;
+};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemfile_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemfile_linker.js
new file mode 100644
index 00000000000..81389763f49
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemfile_linker.js
@@ -0,0 +1,25 @@
+import { createLink, generateHLJSOpenTag } from './dependency_linker_util';
+
+const GEM_URL = 'https://rubygems.org/gems/';
+const GEM_STRING = 'gem </span>';
+const delimiter = '&#39;';
+const stringOpenTag = generateHLJSOpenTag('string', delimiter);
+
+const DEPENDENCY_REGEX = new RegExp(
+ /*
+ * Detects dependencies inside of content that is highlighted by Highlight.js
+ * Example: 'gem </span><span class="hljs-string">&#39;paranoia&#39;'
+ * Group 1 (packageName) : 'paranoia'
+ */
+ `${GEM_STRING}${stringOpenTag}(.+?(?=${delimiter}))`,
+ 'gm',
+);
+
+const handleReplace = (packageName) => {
+ const href = `${GEM_URL}${packageName}`;
+ const packageLink = createLink(href, packageName);
+ return `${GEM_STRING}${stringOpenTag}${packageLink}`;
+};
+export default (result) => {
+ return result.value.replace(DEPENDENCY_REGEX, (_, packageName) => handleReplace(packageName));
+};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker.js
new file mode 100644
index 00000000000..bff8e3cf410
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker.js
@@ -0,0 +1,64 @@
+import { createLink, generateHLJSOpenTag } from './dependency_linker_util';
+
+const PROTOCOL = 'https://';
+const GODOCS_DOMAIN = 'godoc.org/';
+const REPO_PATH = '/tree/master/';
+const GODOCS_REGEX = /golang.org/;
+const GITLAB_REPO_PATH = `/_${REPO_PATH}`;
+const REPO_REGEX = `[^/'"]+/[^/'"]+`;
+const NESTED_REPO_REGEX = '([^/]+/)+[^/]+?';
+const GITHUB_REPO_REGEX = new RegExp(`(github.com/${REPO_REGEX})/(.+)`);
+const GITLAB_REPO_REGEX = new RegExp(`(gitlab.com/${REPO_REGEX})/(.+)`);
+const GITLAB_NESTED_REPO_REGEX = new RegExp(`(gitlab.com/${NESTED_REPO_REGEX}).git/(.+)`);
+const attrOpenTag = generateHLJSOpenTag('attr');
+const stringOpenTag = generateHLJSOpenTag('string');
+const closeTag = '&quot;</span>';
+const importPathString =
+ 'ImportPath&quot;</span><span class="hljs-punctuation">:</span><span class=""> </span>';
+
+const DEPENDENCY_REGEX = new RegExp(
+ /*
+ * Detects dependencies inside of content that is highlighted by Highlight.js
+ * Example: <span class="hljs-attr">&quot;ImportPath&quot;</span><span class="hljs-punctuation">:</span><span class=""> </span><span class="hljs-string">&quot;github.com/ayufan/golang-kardianos-service&quot;</span>
+ * Group 1: github.com/ayufan/golang-kardianos-service
+ */
+ `${importPathString}${stringOpenTag}(.*)${closeTag}`,
+ 'gm',
+);
+
+const replaceRepoPath = (dependency, regex, repoPath) =>
+ dependency.replace(regex, (_, repo, path) => `${PROTOCOL}${repo}${repoPath}${path}`);
+
+const regexConfigs = [
+ {
+ matcher: GITHUB_REPO_REGEX,
+ resolver: (dep) => replaceRepoPath(dep, GITHUB_REPO_REGEX, REPO_PATH),
+ },
+ {
+ matcher: GITLAB_REPO_REGEX,
+ resolver: (dep) => replaceRepoPath(dep, GITLAB_REPO_REGEX, GITLAB_REPO_PATH),
+ },
+ {
+ matcher: GITLAB_NESTED_REPO_REGEX,
+ resolver: (dep) => replaceRepoPath(dep, GITLAB_NESTED_REPO_REGEX, GITLAB_REPO_PATH),
+ },
+ {
+ matcher: GODOCS_REGEX,
+ resolver: (dep) => `${PROTOCOL}${GODOCS_DOMAIN}${dep}`,
+ },
+];
+
+const getLinkHref = (dependency) => {
+ const regexConfig = regexConfigs.find((config) => dependency.match(config.matcher));
+ return regexConfig ? regexConfig.resolver(dependency) : `${PROTOCOL}${dependency}`;
+};
+
+const handleReplace = (dependency) => {
+ const linkHref = getLinkHref(dependency);
+ const link = createLink(linkHref, dependency);
+ return `${importPathString}${attrOpenTag}${link}${closeTag}`;
+};
+
+export default (result) => {
+ return result.value.replace(DEPENDENCY_REGEX, (_, dependency) => handleReplace(dependency));
+};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/podspec_json_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/podspec_json_linker.js
new file mode 100644
index 00000000000..e2007fe408b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/podspec_json_linker.js
@@ -0,0 +1,32 @@
+import { createLink, generateHLJSOpenTag, getObjectKeysByKeyName } from './dependency_linker_util';
+
+const COCOAPODS_URL = 'https://cocoapods.org/pods/';
+const beginString = generateHLJSOpenTag('attr');
+const endString =
+ '&quot;</span><span class="hljs-punctuation">:</span><span class=""> </span><span class="hljs-punctuation">\\[';
+
+const DEPENDENCY_REGEX = new RegExp(
+ /*
+ * Detects dependencies inside of content that is highlighted by Highlight.js
+ * Example: <span class="hljs-attr">&quot;AFNetworking/Security&quot;</span><span class="hljs-punctuation">:</span><span class=""> </span><span class="hljs-punctuation"> [
+ * Group 1: AFNetworking/Serialization
+ */
+ `${beginString}([^/]+/?[^/]+.)${endString}`,
+ 'gm',
+);
+
+const handleReplace = (original, dependency, dependenciesToLink) => {
+ if (dependenciesToLink.includes(dependency)) {
+ const href = `${COCOAPODS_URL}${dependency.split('/')[0]}`;
+ const link = createLink(href, dependency);
+ return `${beginString}${link}${endString.replace('\\', '')}`;
+ }
+ return original;
+};
+
+export default (result, raw) => {
+ const dependenciesToLink = getObjectKeysByKeyName(JSON.parse(raw), 'dependencies', []);
+ return result.value.replace(DEPENDENCY_REGEX, (original, dependency) =>
+ handleReplace(original, dependency, dependenciesToLink),
+ );
+};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js
index e0ba4b730a7..3540ac6caf1 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js
@@ -22,7 +22,7 @@ const format = (node, kind = '') => {
.split(newlineRegex)
.map((newline) => generateHLJSTag(kind, newline, true))
.join('\n');
- } else if (node.kind) {
+ } else if (node.kind || node.sublanguage) {
const { children } = node;
if (children.length && children.length === 1) {
buffer += format(children[0], node.kind);
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
index 536b2c8a281..f621a23734a 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
@@ -65,6 +65,9 @@ export default {
!supportedLanguages.includes(this.blob.language?.toLowerCase())
);
},
+ totalChunks() {
+ return Object.keys(this.chunks).length;
+ },
},
async created() {
addBlobLinksTracking();
@@ -200,6 +203,7 @@ export default {
:content="firstChunk.content"
:starting-from="firstChunk.startingFrom"
:is-highlighted="firstChunk.isHighlighted"
+ is-first-chunk
:language="firstChunk.language"
:blame-path="blob.blamePath"
/>
@@ -217,6 +221,7 @@ export default {
:chunk-index="index"
:language="chunk.language"
:blame-path="blob.blamePath"
+ :total-chunks="totalChunks"
@appear="highlightChunk"
/>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js
index e621442e601..84615386fe2 100644
--- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js
+++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js
@@ -1,4 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import TooltipOnTruncate from './tooltip_on_truncate.vue';
const defaultWidth = '250px';
diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js
index 1f0f4cde234..0815fdd9aac 100644
--- a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js
+++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js
@@ -1,5 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-
import { OBSTACLE_TYPES } from './constants';
import UserDeletionObstaclesList from './user_deletion_obstacles_list.vue';
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 7e735f358eb..30b7b073ac3 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
@@ -3,7 +3,7 @@ import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isScopedLabel } from '~/lib/utils/common_utils';
-import { differenceInSeconds, getTimeago, SECONDS_IN_DAY } from '~/lib/utils/datetime_utility';
+import { getTimeago } from '~/lib/utils/datetime_utility';
import { isExternal, setUrlFragment } from '~/lib/utils/url_utility';
import { __, n__, sprintf } from '~/locale';
import IssuableAssignees from '~/issuable/components/issue_assignees.vue';
@@ -62,9 +62,8 @@ export default {
issuableId() {
return getIdFromGraphQLId(this.issuable.id);
},
- createdInPastDay() {
- const createdSecondsAgo = differenceInSeconds(new Date(this.issuable.createdAt), new Date());
- return createdSecondsAgo < SECONDS_IN_DAY;
+ issuableIid() {
+ return this.issuable.iid;
},
author() {
return this.issuable.author || {};
@@ -184,7 +183,7 @@ export default {
<li
:id="`issuable_${issuableId}`"
class="issue gl-display-flex! gl-px-5!"
- :class="{ closed: issuable.closedAt, today: createdInPastDay }"
+ :class="{ closed: issuable.closedAt }"
:data-labels="labelIdsString"
:data-qa-issue-id="issuableId"
>
@@ -193,6 +192,8 @@ export default {
class="issue-check gl-mr-0"
:checked="checked"
:data-id="issuableId"
+ :data-iid="issuableIid"
+ :data-type="issuable.type"
@input="$emit('checked-input', $event)"
>
<span class="gl-sr-only">{{ issuable.title }}</span>
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 bc10f84b819..dd3d7c8f4d6 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
@@ -7,6 +7,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import issuableEventHub from '~/issues/list/eventhub';
import { DEFAULT_SKELETON_COUNT, PAGE_SIZE_STORAGE_KEY } from '../constants';
import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue';
import IssuableItem from './issuable_item.vue';
@@ -177,6 +178,11 @@ export default {
required: false,
default: false,
},
+ showFilteredSearchFriendlyText: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
showPageSizeChangeControls: {
type: Boolean,
required: false,
@@ -266,6 +272,7 @@ export default {
handleIssuableCheckedInput(issuable, value) {
this.checkedIssuables[this.issuableId(issuable)].checked = value;
this.$emit('update-legacy-bulk-edit');
+ issuableEventHub.$emit('issuables:issuableChecked', issuable, value);
},
handleAllIssuablesCheckedInput(value) {
Object.keys(this.checkedIssuables).forEach((issuableId) => {
@@ -308,6 +315,7 @@ export default {
:sync-filter-and-sort="syncFilterAndSort"
:show-checkbox="showBulkEditSidebar"
:checkbox-checked="allIssuablesChecked"
+ :show-friendly-text="showFilteredSearchFriendlyText"
class="gl-flex-grow-1 gl-border-t-none row-content-block"
data-qa-selector="issuable_search_container"
@checked-input="handleAllIssuablesCheckedInput"
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/utils.js b/app/assets/javascripts/vue_shared/security_reports/store/utils.js
index 6a4f671abb9..a6628fa0f9f 100644
--- a/app/assets/javascripts/vue_shared/security_reports/store/utils.js
+++ b/app/assets/javascripts/vue_shared/security_reports/store/utils.js
@@ -90,7 +90,7 @@ const createStatusMessage = ({ reportType, status, total }) => {
if (status) {
message = __('%{reportType} %{status}');
} else if (!total) {
- message = __('%{reportType} detected no %{totalStart}new%{totalEnd} vulnerabilities.');
+ message = __('%{reportType} detected no new vulnerabilities.');
} else {
message = __(
'%{reportType} detected %{totalStart}%{total}%{totalEnd} potential %{vulnMessage}',
diff --git a/app/assets/javascripts/webhooks/components/form_url_app.vue b/app/assets/javascripts/webhooks/components/form_url_app.vue
index 5ec16d4ba15..4fafeff8804 100644
--- a/app/assets/javascripts/webhooks/components/form_url_app.vue
+++ b/app/assets/javascripts/webhooks/components/form_url_app.vue
@@ -1,7 +1,8 @@
<script>
-import { isEmpty } from 'lodash';
+import { cloneDeep, isEmpty } from 'lodash';
import { GlFormGroup, GlFormInput, GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui';
import { __, s__ } from '~/locale';
+import { scrollToElement } from '~/lib/utils/common_utils';
import FormUrlMaskItem from './form_url_mask_item.vue';
@@ -30,10 +31,15 @@ export default {
return {
maskEnabled: !isEmpty(this.initialUrlVariables),
url: this.initialUrl,
- items: isEmpty(this.initialUrlVariables) ? [{}] : this.initialUrlVariables,
+ items: this.getInitialItems(),
+ isValidated: false,
+ formEl: null,
};
},
computed: {
+ urlState() {
+ return !this.isValidated || !isEmpty(this.url);
+ },
maskedUrl() {
if (!this.url) {
return null;
@@ -46,14 +52,83 @@ export default {
return;
}
- const replacementExpression = new RegExp(value, 'g');
- maskedUrl = maskedUrl.replace(replacementExpression, `{${key}}`);
+ maskedUrl = this.maskUrl(maskedUrl, key, value);
});
return maskedUrl;
},
},
+ mounted() {
+ this.formEl = document.querySelector('.js-webhook-form');
+
+ this.formEl?.addEventListener('submit', this.handleSubmit);
+ },
+ destroy() {
+ this.formEl?.removeEventListener('submit', this.handleSubmit);
+ },
methods: {
+ getInitialItems() {
+ return isEmpty(this.initialUrlVariables) ? [{}] : cloneDeep(this.initialUrlVariables);
+ },
+ isEditingItem(index, key) {
+ if (isEmpty(this.initialUrlVariables)) {
+ return false;
+ }
+
+ const item = this.initialUrlVariables[index];
+ return item && item.key === key;
+ },
+ keyInvalidFeedback(key) {
+ if (this.isValidated && isEmpty(key)) {
+ return this.$options.i18n.inputRequired;
+ }
+
+ return null;
+ },
+ valueInvalidFeedback(index, key, value) {
+ if (this.isEditingItem(index, key)) {
+ return null;
+ }
+
+ if (this.isValidated && isEmpty(value)) {
+ return this.$options.i18n.inputRequired;
+ }
+
+ if (!isEmpty(value) && !this.url?.includes(value)) {
+ return this.$options.i18n.valuePartOfUrl;
+ }
+
+ return null;
+ },
+ isValid() {
+ this.isValidated = true;
+
+ if (!this.urlState) {
+ return false;
+ }
+
+ if (
+ this.maskEnabled &&
+ this.items.some(
+ ({ key, value }, index) =>
+ this.keyInvalidFeedback(key) || this.valueInvalidFeedback(index, key, value),
+ )
+ ) {
+ return false;
+ }
+
+ return true;
+ },
+ handleSubmit(e) {
+ if (!this.isValid()) {
+ scrollToElement(this.$refs.formUrl.$el);
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ },
+ maskUrl(url, key, value) {
+ return url.split(value).join(`{${key}}`);
+ },
onItemInput({ index, key, value }) {
this.$set(this.items, index, { key, value });
},
@@ -66,6 +141,7 @@ export default {
},
i18n: {
addItem: s__('Webhooks|+ Mask another portion of URL'),
+ inputRequired: __('This field is required.'),
radioFullUrlText: s__('Webhooks|Show full URL'),
radioMaskUrlText: s__('Webhooks|Mask portions of URL'),
radioMaskUrlHelp: s__('Webhooks|Do not show sensitive data such as tokens in the UI.'),
@@ -75,6 +151,7 @@ export default {
urlLabel: __('URL'),
urlPlaceholder: 'http://example.com/trigger-ci.json',
urlPreview: s__('Webhooks|URL preview'),
+ valuePartOfUrl: s__('Webhooks|Must match part of URL'),
},
};
</script>
@@ -82,14 +159,18 @@ export default {
<template>
<div>
<gl-form-group
+ ref="formUrl"
:label="$options.i18n.urlLabel"
label-for="webhook-url"
:description="$options.i18n.urlDescription"
+ :invalid-feedback="$options.i18n.inputRequired"
+ :state="urlState"
>
<gl-form-input
id="webhook-url"
v-model="url"
name="hook[url]"
+ :state="urlState"
:placeholder="$options.i18n.urlPlaceholder"
data-testid="form-url"
/>
@@ -112,6 +193,9 @@ export default {
:index="index"
:item-key="key"
:item-value="value"
+ :is-editing="isEditingItem(index, key)"
+ :key-invalid-feedback="keyInvalidFeedback(key)"
+ :value-invalid-feedback="valueInvalidFeedback(index, key, value)"
@input="onItemInput"
@remove="removeItem"
/>
diff --git a/app/assets/javascripts/webhooks/components/form_url_mask_item.vue b/app/assets/javascripts/webhooks/components/form_url_mask_item.vue
index 3b75f9b6c0d..f5f81759719 100644
--- a/app/assets/javascripts/webhooks/components/form_url_mask_item.vue
+++ b/app/assets/javascripts/webhooks/components/form_url_mask_item.vue
@@ -1,6 +1,8 @@
<script>
+import { isEmpty } from 'lodash';
import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui';
import { s__ } from '~/locale';
+import { MASK_ITEM_VALUE_HIDDEN } from '../constants';
export default {
components: {
@@ -24,6 +26,21 @@ export default {
required: false,
default: null,
},
+ isEditing: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ keyInvalidFeedback: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ valueInvalidFeedback: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
keyInputId() {
@@ -32,6 +49,15 @@ export default {
valueInputId() {
return this.inputId('value');
},
+ keyState() {
+ return isEmpty(this.keyInvalidFeedback);
+ },
+ valueState() {
+ return isEmpty(this.valueInvalidFeedback);
+ },
+ displayValue() {
+ return this.isEditing ? MASK_ITEM_VALUE_HIDDEN : this.itemValue;
+ },
},
methods: {
inputId(type) {
@@ -58,23 +84,29 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-align-items-flex-end gl-gap-3 gl-mb-3">
+ <div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-mb-3">
<gl-form-group
:label="$options.i18n.valueLabel"
:label-for="valueInputId"
+ :invalid-feedback="valueInvalidFeedback"
+ :state="valueState"
class="gl-flex-grow-1 gl-mb-0"
data-testid="mask-item-value"
>
<gl-form-input
:id="valueInputId"
:name="inputName('value')"
- :value="itemValue"
+ :value="displayValue"
+ :disabled="isEditing"
+ :state="valueState"
@input="onValueInput"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.keyLabel"
:label-for="keyInputId"
+ :invalid-feedback="keyInvalidFeedback"
+ :state="keyState"
class="gl-flex-grow-1 gl-mb-0"
data-testid="mask-item-key"
>
@@ -82,9 +114,17 @@ export default {
:id="keyInputId"
:name="inputName('key')"
:value="itemKey"
+ :disabled="isEditing"
+ :state="keyState"
@input="onKeyInput"
/>
</gl-form-group>
- <gl-button icon="remove" :aria-label="__('Remove')" @click="onRemoveClick" />
+ <gl-button
+ icon="remove"
+ :aria-label="__('Remove')"
+ :disabled="isEditing"
+ class="gl-mt-6"
+ @click="onRemoveClick"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/webhooks/components/push_events.vue b/app/assets/javascripts/webhooks/components/push_events.vue
new file mode 100644
index 00000000000..677f06314e0
--- /dev/null
+++ b/app/assets/javascripts/webhooks/components/push_events.vue
@@ -0,0 +1,112 @@
+<script>
+import { GlFormCheckbox, GlFormRadio, GlFormRadioGroup, GlFormInput, GlSprintf } from '@gitlab/ui';
+import {
+ BRANCH_FILTER_ALL_BRANCHES,
+ WILDCARD_CODE_STABLE,
+ WILDCARD_CODE_PRODUCTION,
+ REGEX_CODE,
+ descriptionText,
+} from '~/webhooks/constants';
+
+export default {
+ components: {
+ GlFormCheckbox,
+ GlFormRadio,
+ GlFormRadioGroup,
+ GlFormInput,
+ GlSprintf,
+ },
+ inject: ['pushEvents', 'strategy', 'isNewHook', 'pushEventsBranchFilter'],
+ data() {
+ return {
+ pushEventsData: !this.isNewHook && this.pushEvents,
+ branchFilterStrategyData: this.isNewHook ? BRANCH_FILTER_ALL_BRANCHES : this.strategy,
+ pushEventsBranchFilterData: this.pushEventsBranchFilter,
+ };
+ },
+ WILDCARD_CODE_STABLE,
+ WILDCARD_CODE_PRODUCTION,
+ REGEX_CODE,
+ descriptionText,
+};
+</script>
+
+<template>
+ <div>
+ <gl-form-checkbox v-model="pushEventsData">{{ s__('Webhooks|Push events') }}</gl-form-checkbox>
+ <input type="hidden" :value="pushEventsData" name="hook[push_events]" />
+
+ <div v-if="pushEventsData" class="gl-pl-6">
+ <gl-form-radio-group v-model="branchFilterStrategyData" name="hook[branch_filter_strategy]">
+ <gl-form-radio
+ class="gl-mt-2 branch-filter-strategy-radio"
+ value="all_branches"
+ data-testid="rule_all_branches"
+ >
+ <div data-qa-selector="strategy_radio_all">{{ __('All branches') }}</div>
+ </gl-form-radio>
+
+ <!-- wildcard -->
+ <gl-form-radio
+ class="gl-mt-2 branch-filter-strategy-radio"
+ value="wildcard"
+ data-testid="rule_wildcard"
+ >
+ <div data-qa-selector="strategy_radio_wildcard">
+ {{ s__('Webhooks|Wildcard pattern') }}
+ </div>
+ </gl-form-radio>
+ <div class="gl-ml-6">
+ <gl-form-input
+ v-if="branchFilterStrategyData === 'wildcard'"
+ v-model="pushEventsBranchFilterData"
+ name="hook[push_events_branch_filter]"
+ data-qa-selector="webhook_branch_filter_field"
+ data-testid="webhook_branch_filter_field"
+ />
+ </div>
+ <p
+ v-if="branchFilterStrategyData === 'wildcard'"
+ class="form-text text-muted custom-control"
+ >
+ <gl-sprintf :message="$options.descriptionText.wildcard">
+ <template #WILDCARD_CODE_STABLE>
+ <code>{{ $options.WILDCARD_CODE_STABLE }}</code>
+ </template>
+ <template #WILDCARD_CODE_PRODUCTION>
+ <code>{{ $options.WILDCARD_CODE_PRODUCTION }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <!-- regex -->
+ <gl-form-radio
+ class="gl-mt-2 branch-filter-strategy-radio"
+ value="regex"
+ data-testid="rule_regex"
+ >
+ <div data-qa-selector="strategy_radio_regex">
+ {{ s__('Webhooks|Regular expression') }}
+ </div>
+ </gl-form-radio>
+ <div class="gl-ml-6">
+ <gl-form-input
+ v-if="branchFilterStrategyData === 'regex'"
+ v-model="pushEventsBranchFilterData"
+ name="hook[push_events_branch_filter]"
+ data-qa-selector="webhook_branch_filter_field"
+ data-testid="webhook_branch_filter_field"
+ />
+ </div>
+
+ <p v-if="branchFilterStrategyData === 'regex'" class="form-text text-muted custom-control">
+ <gl-sprintf :message="$options.descriptionText.regex">
+ <template #REGEX_CODE>
+ <code>{{ $options.REGEX_CODE }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-form-radio-group>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/webhooks/constants.js b/app/assets/javascripts/webhooks/constants.js
new file mode 100644
index 00000000000..6710a418117
--- /dev/null
+++ b/app/assets/javascripts/webhooks/constants.js
@@ -0,0 +1,19 @@
+import { s__ } from '~/locale';
+
+export const BRANCH_FILTER_ALL_BRANCHES = 'all_branches';
+export const BRANCH_FILTER_WILDCARD = 'wildcard';
+export const BRANCH_FILTER_REGEX = 'regex';
+
+export const WILDCARD_CODE_STABLE = '*-stable';
+export const WILDCARD_CODE_PRODUCTION = 'production/*';
+
+export const REGEX_CODE = '(feature|hotfix)/*';
+
+export const descriptionText = {
+ [BRANCH_FILTER_WILDCARD]: s__(
+ 'Webhooks|Wildcards such as %{WILDCARD_CODE_STABLE} or %{WILDCARD_CODE_PRODUCTION} are supported.',
+ ),
+ [BRANCH_FILTER_REGEX]: s__('Webhooks|Regex such as %{REGEX_CODE} is supported.'),
+};
+
+export const MASK_ITEM_VALUE_HIDDEN = '************';
diff --git a/app/assets/javascripts/webhooks/index.js b/app/assets/javascripts/webhooks/index.js
index 1b2b33e44c1..7d04978280b 100644
--- a/app/assets/javascripts/webhooks/index.js
+++ b/app/assets/javascripts/webhooks/index.js
@@ -17,7 +17,7 @@ export default () => {
return createElement(FormUrlApp, {
props: {
initialUrl,
- initialUrlVariables: urlVariables ? JSON.parse(urlVariables) : undefined,
+ initialUrlVariables: JSON.parse(urlVariables),
},
});
},
diff --git a/app/assets/javascripts/webhooks/webhook.js b/app/assets/javascripts/webhooks/webhook.js
new file mode 100644
index 00000000000..ca631502745
--- /dev/null
+++ b/app/assets/javascripts/webhooks/webhook.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import pushEvents from './components/push_events.vue';
+
+export function initPushEventsEditForm() {
+ const el = document.querySelector('.js-vue-push-events');
+
+ if (!el) return false;
+
+ const provide = {
+ isNewHook: parseBoolean(el.dataset.isNewHook),
+ pushEvents: parseBoolean(el.dataset.pushEvents),
+ strategy: el.dataset.strategy,
+ pushEventsBranchFilter: el.dataset.pushEventsBranchFilter,
+ };
+ return new Vue({
+ el,
+ provide,
+ render(createElement) {
+ return createElement(pushEvents);
+ },
+ });
+}
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 57babe4569d..57930951856 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlFormGroup, GlSafeHtmlDirective } from '@gitlab/ui';
+import { GlButton, GlFormGroup } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { helpPagePath } from '~/helpers/help_page_helper';
import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
@@ -7,22 +7,25 @@ import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_m
import { __, s__ } from '~/locale';
import EditedAt from '~/issues/show/components/edited.vue';
import Tracking from '~/tracking';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
-import workItemQuery from '../graphql/work_item.query.graphql';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+import { getWorkItemQuery } from '../utils';
+import workItemDescriptionSubscription from '../graphql/work_item_description.subscription.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants';
+import WorkItemDescriptionRendered from './work_item_description_rendered.vue';
export default {
- directives: {
- SafeHtml: GlSafeHtmlDirective,
- },
components: {
EditedAt,
GlButton,
GlFormGroup,
+ MarkdownEditor,
MarkdownField,
+ WorkItemDescriptionRendered,
},
- mixins: [Tracking.mixin()],
+ mixins: [glFeatureFlagMixin(), Tracking.mixin()],
props: {
workItemId: {
type: String,
@@ -32,6 +35,15 @@ export default {
type: String,
required: true,
},
+ fetchByIid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
},
markdownDocsPath: helpPagePath('user/markdown'),
data() {
@@ -41,21 +53,37 @@ export default {
isSubmitting: false,
isSubmittingWithKeydown: false,
descriptionText: '',
+ descriptionHtml: '',
};
},
apollo: {
workItem: {
- query: workItemQuery,
+ query() {
+ return getWorkItemQuery(this.fetchByIid);
+ },
variables() {
- return {
- id: this.workItemId,
- };
+ return this.queryVariables;
+ },
+ update(data) {
+ return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
},
skip() {
return !this.workItemId;
},
+ result() {
+ this.descriptionText = this.workItemDescription?.description;
+ this.descriptionHtml = this.workItemDescription?.descriptionHtml;
+ },
error() {
- this.error = i18n.fetchError;
+ this.$emit('error', i18n.fetchError);
+ },
+ subscribeToMore: {
+ document: workItemDescriptionSubscription,
+ variables() {
+ return {
+ issuableId: this.workItemId,
+ };
+ },
},
},
},
@@ -64,7 +92,7 @@ export default {
return this.workItemId;
},
canEdit() {
- return this.workItem?.userPermissions?.updateWorkItem;
+ return this.workItem?.userPermissions?.updateWorkItem || false;
},
tracking() {
return {
@@ -73,12 +101,6 @@ export default {
property: `type_${this.workItemType}`,
};
},
- descriptionHtml() {
- return this.workItemDescription?.descriptionHtml;
- },
- descriptionEmpty() {
- return this.descriptionHtml?.trim() === '';
- },
workItemDescription() {
const descriptionWidget = this.workItem?.widgets?.find(
(widget) => widget.type === WIDGET_TYPE_DESCRIPTION,
@@ -114,7 +136,7 @@ export default {
await this.$nextTick();
- this.$refs.textarea.focus();
+ this.$refs.textarea?.focus();
},
async cancelEditing() {
const isDirty = this.descriptionText !== this.workItemDescription?.description;
@@ -142,8 +164,10 @@ export default {
updateDraft(this.autosaveKey, this.descriptionText);
},
- async updateWorkItem(event) {
- if (event.key) {
+ async updateWorkItem(event = {}) {
+ const { key } = event;
+
+ if (key) {
this.isSubmittingWithKeydown = true;
}
@@ -179,73 +203,90 @@ export default {
this.isSubmitting = false;
},
+ setDescriptionText(newText) {
+ this.descriptionText = newText;
+ updateDraft(this.autosaveKey, this.descriptionText);
+ },
+ handleDescriptionTextUpdated(newText) {
+ this.descriptionText = newText;
+ this.updateWorkItem();
+ },
},
};
</script>
<template>
- <gl-form-group
- v-if="isEditing"
- class="gl-my-5 gl-border-t gl-pt-6"
- :label="__('Description')"
- label-for="work-item-description"
- >
- <markdown-field
- can-attach-file
- :textarea-value="descriptionText"
- :is-submitting="isSubmitting"
- :markdown-preview-path="markdownPreviewPath"
- :markdown-docs-path="$options.markdownDocsPath"
- class="gl-p-3 bordered-box gl-mt-5"
+ <div>
+ <gl-form-group
+ v-if="isEditing"
+ class="gl-mb-5 gl-border-t gl-pt-6"
+ :label="__('Description')"
+ label-for="work-item-description"
>
- <template #textarea>
- <textarea
- id="work-item-description"
- ref="textarea"
- v-model="descriptionText"
- :disabled="isSubmitting"
- class="note-textarea js-gfm-input js-autosize markdown-area"
- dir="auto"
- data-supports-quick-actions="false"
- :aria-label="__('Description')"
- :placeholder="__('Write a comment or drag your files here…')"
- @keydown.meta.enter="updateWorkItem"
- @keydown.ctrl.enter="updateWorkItem"
- @keydown.exact.esc.stop="cancelEditing"
- @input="onInput"
- ></textarea>
- </template>
- </markdown-field>
-
- <div class="gl-display-flex">
- <gl-button
- category="primary"
- variant="confirm"
- :loading="isSubmitting"
- data-testid="save-description"
- @click="updateWorkItem"
- >{{ __('Save') }}</gl-button
- >
- <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="cancelEditing">{{
- __('Cancel')
- }}</gl-button>
- </div>
- </gl-form-group>
- <div v-else class="gl-mb-5 gl-border-t">
- <div class="gl-display-inline-flex gl-align-items-center gl-mb-5">
- <label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label>
- <gl-button
- v-if="canEdit"
- class="gl-ml-auto"
- icon="pencil"
- data-testid="edit-description"
- :aria-label="__('Edit description')"
- @click="startEditing"
+ <markdown-editor
+ v-if="glFeatures.workItemsMvc2"
+ class="gl-my-3 common-note-form"
+ :value="descriptionText"
+ :render-markdown-path="markdownPreviewPath"
+ :markdown-docs-path="$options.markdownDocsPath"
+ :form-field-aria-label="__('Description')"
+ :form-field-placeholder="__('Write a comment or drag your files here…')"
+ form-field-id="work-item-description"
+ form-field-name="work-item-description"
+ enable-autocomplete
+ init-on-autofocus
+ @input="setDescriptionText"
+ @keydown.meta.enter="updateWorkItem"
+ @keydown.ctrl.enter="updateWorkItem"
/>
- </div>
-
- <div v-if="descriptionEmpty" class="gl-text-secondary gl-mb-5">{{ __('None') }}</div>
- <div v-else v-safe-html="descriptionHtml" class="md gl-mb-5 gl-min-h-8"></div>
+ <markdown-field
+ v-else
+ can-attach-file
+ :textarea-value="descriptionText"
+ :is-submitting="isSubmitting"
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="$options.markdownDocsPath"
+ class="gl-p-3 bordered-box gl-mt-5"
+ >
+ <template #textarea>
+ <textarea
+ id="work-item-description"
+ ref="textarea"
+ v-model="descriptionText"
+ :disabled="isSubmitting"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ dir="auto"
+ data-supports-quick-actions="false"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment or drag your files here…')"
+ @keydown.meta.enter="updateWorkItem"
+ @keydown.ctrl.enter="updateWorkItem"
+ @keydown.exact.esc.stop="cancelEditing"
+ @input="onInput"
+ ></textarea>
+ </template>
+ </markdown-field>
+ <div class="gl-display-flex">
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :loading="isSubmitting"
+ data-testid="save-description"
+ @click="updateWorkItem"
+ >{{ __('Save') }}
+ </gl-button>
+ <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="cancelEditing"
+ >{{ __('Cancel') }}
+ </gl-button>
+ </div>
+ </gl-form-group>
+ <work-item-description-rendered
+ v-else
+ :work-item-description="workItemDescription"
+ :can-edit="canEdit"
+ @startEditing="startEditing"
+ @descriptionUpdated="handleDescriptionTextUpdated"
+ />
<edited-at
v-if="lastEditedAt"
:updated-at="lastEditedAt"
diff --git a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue
new file mode 100644
index 00000000000..e6f8a301c5e
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue
@@ -0,0 +1,117 @@
+<script>
+import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui';
+import $ from 'jquery';
+import '~/behaviors/markdown/render_gfm';
+
+const isCheckbox = (target) => target?.classList.contains('task-list-item-checkbox');
+
+export default {
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ components: {
+ GlButton,
+ },
+ props: {
+ workItemDescription: {
+ type: Object,
+ required: true,
+ },
+ canEdit: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ descriptionText() {
+ return this.workItemDescription?.description;
+ },
+ descriptionHtml() {
+ return this.workItemDescription?.descriptionHtml;
+ },
+ descriptionEmpty() {
+ return this.descriptionHtml?.trim() === '';
+ },
+ },
+ watch: {
+ descriptionHtml: {
+ handler() {
+ this.renderGFM();
+ },
+ immediate: true,
+ },
+ },
+ methods: {
+ async renderGFM() {
+ await this.$nextTick();
+
+ $(this.$refs['gfm-content']).renderGFM();
+
+ if (this.canEdit) {
+ this.checkboxes = this.$el.querySelectorAll('.task-list-item-checkbox');
+
+ // enable boxes, disabled by default in markdown
+ this.checkboxes.forEach((checkbox) => {
+ // eslint-disable-next-line no-param-reassign
+ checkbox.disabled = false;
+ });
+ }
+ },
+ toggleCheckboxes(event) {
+ const { target } = event;
+
+ if (isCheckbox(target)) {
+ target.disabled = true;
+
+ const { sourcepos } = target.parentElement.dataset;
+
+ if (!sourcepos) return;
+
+ const [startRange] = sourcepos.split('-');
+ let [startRow] = startRange.split(':');
+ startRow = Number(startRow) - 1;
+
+ const descriptionTextRows = this.descriptionText.split('\n');
+ const newDescriptionText = descriptionTextRows
+ .map((row, index) => {
+ if (startRow === index) {
+ if (target.checked) {
+ return row.replace(/\[ \]/, '[x]');
+ }
+ return row.replace(/\[[x~]\]/i, '[ ]');
+ }
+ return row;
+ })
+ .join('\n');
+
+ this.$emit('descriptionUpdated', newDescriptionText);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-mb-5 gl-border-t gl-pt-5">
+ <div class="gl-display-inline-flex gl-align-items-center gl-mb-5">
+ <label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label>
+ <gl-button
+ v-if="canEdit"
+ class="gl-ml-auto"
+ icon="pencil"
+ data-testid="edit-description"
+ :aria-label="__('Edit description')"
+ @click="$emit('startEditing')"
+ />
+ </div>
+
+ <div v-if="descriptionEmpty" class="gl-text-secondary gl-mb-5">{{ __('None') }}</div>
+ <div
+ v-else
+ ref="gfm-content"
+ v-safe-html="descriptionHtml"
+ class="md gl-mb-5 gl-min-h-8"
+ @change="toggleCheckboxes"
+ ></div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue
index af9b8c6101a..7e9fa24e3f5 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -1,4 +1,5 @@
<script>
+import { isEmpty } from 'lodash';
import {
GlAlert,
GlSkeletonLoader,
@@ -11,6 +12,7 @@ import {
} from '@gitlab/ui';
import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg';
import { s__ } from '~/locale';
+import { parseBoolean } from '~/lib/utils/common_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
@@ -27,12 +29,13 @@ import {
WIDGET_TYPE_ITERATION,
} from '../constants';
-import workItemQuery from '../graphql/work_item.query.graphql';
import workItemDatesSubscription from '../graphql/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 WorkItemActions from './work_item_actions.vue';
import WorkItemState from './work_item_state.vue';
@@ -72,6 +75,7 @@ export default {
WorkItemMilestone,
},
mixins: [glFeatureFlagMixin()],
+ inject: ['fullPath'],
props: {
isModal: {
type: Boolean,
@@ -83,6 +87,11 @@ export default {
required: false,
default: null,
},
+ iid: {
+ type: String,
+ required: false,
+ default: null,
+ },
workItemParentId: {
type: String,
required: false,
@@ -100,20 +109,26 @@ export default {
},
apollo: {
workItem: {
- query: workItemQuery,
+ query() {
+ return getWorkItemQuery(this.fetchByIid);
+ },
variables() {
- return {
- id: this.workItemId,
- };
+ return this.queryVariables;
},
skip() {
return !this.workItemId;
},
+ update(data) {
+ const workItem = this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ return workItem ?? {};
+ },
error() {
- this.error = this.$options.i18n.fetchError;
- document.title = s__('404|Not found');
+ this.setEmptyState();
},
result() {
+ if (isEmpty(this.workItem)) {
+ this.setEmptyState();
+ }
if (!this.isModal && this.workItem.project) {
const path = this.workItem.project?.fullPath
? ` · ${this.workItem.project.fullPath}`
@@ -127,30 +142,44 @@ export default {
document: workItemTitleSubscription,
variables() {
return {
- issuableId: this.workItemId,
+ issuableId: this.workItem.id,
};
},
+ skip() {
+ return !this.workItem?.id;
+ },
},
{
document: workItemDatesSubscription,
variables() {
return {
- issuableId: this.workItemId,
+ issuableId: this.workItem.id,
};
},
skip() {
- return !this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE);
+ return !this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE) || !this.workItem?.id;
},
},
{
document: workItemAssigneesSubscription,
variables() {
return {
- issuableId: this.workItemId,
+ issuableId: this.workItem.id,
+ };
+ },
+ skip() {
+ return !this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES) || !this.workItem?.id;
+ },
+ },
+ {
+ document: workItemMilestoneSubscription,
+ variables() {
+ return {
+ issuableId: this.workItem.id,
};
},
skip() {
- return !this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES);
+ return !this.isWidgetPresent(WIDGET_TYPE_MILESTONE) || !this.workItem?.id;
},
},
],
@@ -212,7 +241,20 @@ export default {
return this.isWidgetPresent(WIDGET_TYPE_ITERATION);
},
workItemMilestone() {
- return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_MILESTONE);
+ return this.isWidgetPresent(WIDGET_TYPE_MILESTONE);
+ },
+ fetchByIid() {
+ return this.glFeatures.useIidInWorkItemsPath && parseBoolean(this.$route.query.iid_path);
+ },
+ queryVariables() {
+ return this.fetchByIid
+ ? {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ }
+ : {
+ id: this.workItemId,
+ };
},
},
beforeDestroy() {
@@ -231,7 +273,7 @@ export default {
this.updateInProgress = true;
let updateMutation = updateWorkItemMutation;
let inputVariables = {
- id: this.workItemId,
+ id: this.workItem.id,
confidential: confidentialStatus,
};
@@ -240,7 +282,7 @@ export default {
inputVariables = {
id: this.parentWorkItem.id,
taskData: {
- id: this.workItemId,
+ id: this.workItem.id,
confidential: confidentialStatus,
},
};
@@ -275,6 +317,10 @@ export default {
this.updateInProgress = false;
});
},
+ setEmptyState() {
+ this.error = this.$options.i18n.fetchError;
+ document.title = s__('404|Not found');
+ },
},
WORK_ITEM_VIEWED_STORAGE_KEY,
};
@@ -352,7 +398,7 @@ export default {
:can-update="canUpdate"
:is-confidential="workItem.confidential"
:is-parent-confidential="parentWorkItemConfidentiality"
- @deleteWorkItem="$emit('deleteWorkItem', workItemType)"
+ @deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })"
@toggleWorkItemConfidentiality="toggleConfidentiality"
@error="updateError = $event"
/>
@@ -406,6 +452,8 @@ export default {
: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
@@ -421,8 +469,10 @@ export default {
<work-item-milestone
v-if="workItemMilestone"
:work-item-id="workItem.id"
- :work-item-milestone="workItemMilestone.nodes[0]"
+ :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"
@@ -435,6 +485,8 @@ export default {
:weight="workItemWeight.weight"
:work-item-id="workItem.id"
:work-item-type="workItemType"
+ :fetch-by-iid="fetchByIid"
+ :query-variables="queryVariables"
@error="updateError = $event"
/>
<template v-if="workItemsMvc2Enabled">
@@ -445,6 +497,9 @@ export default {
: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"
/>
</template>
@@ -452,6 +507,8 @@ export default {
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"
/>
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 eae11c2bb2f..9ee302855c7 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
@@ -134,12 +134,12 @@ export default {
async clickShowDueDate() {
this.showDueDateInput = true;
await this.$nextTick();
- this.$refs.dueDatePicker.calendar.show();
+ this.$refs.dueDatePicker.show();
},
async clickShowStartDate() {
this.showStartDateInput = true;
await this.$nextTick();
- this.$refs.startDatePicker.calendar.show();
+ this.$refs.startDatePicker.show();
},
handleStartDateInput() {
if (this.dirtyDueDate && this.dirtyStartDate > this.dirtyDueDate) {
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 05077862690..22af3c653e9 100644
--- a/app/assets/javascripts/work_items/components/work_item_labels.vue
+++ b/app/assets/javascripts/work_items/components/work_item_labels.vue
@@ -8,7 +8,7 @@ import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/labe
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 workItemQuery from '../graphql/work_item.query.graphql';
+import { getWorkItemQuery } from '../utils';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import {
@@ -50,6 +50,15 @@ export default {
type: String,
required: true,
},
+ fetchByIid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -64,11 +73,14 @@ export default {
},
apollo: {
workItem: {
- query: workItemQuery,
+ query() {
+ return getWorkItemQuery(this.fetchByIid);
+ },
variables() {
- return {
- id: this.workItemId,
- };
+ return this.queryVariables;
+ },
+ update(data) {
+ return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
},
skip() {
return !this.workItemId;
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 37aa48be6e5..0251dcc33fa 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
@@ -6,10 +6,6 @@ import WorkItemLinks from './work_item_links.vue';
Vue.use(GlToast);
export default function initWorkItemLinks() {
- if (!window.gon.features.workItemsHierarchy) {
- return;
- }
-
const workItemLinksRoot = document.querySelector('.js-work-item-links-root');
if (!workItemLinksRoot) {
@@ -21,7 +17,6 @@ export default function initWorkItemLinks() {
wiHasIssueWeightsFeature,
iid,
wiHasIterationsFeature,
- projectNamespace,
} = workItemLinksRoot.dataset;
// eslint-disable-next-line no-new
@@ -38,7 +33,6 @@ export default function initWorkItemLinks() {
fullPath: projectPath,
hasIssueWeightsFeature: wiHasIssueWeightsFeature,
hasIterationsFeature: wiHasIterationsFeature,
- projectNamespace,
},
render: (createElement) =>
createElement('work-item-links', {
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 0d3e951de7e..3d469b790a1 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
@@ -1,5 +1,13 @@
<script>
-import { GlButton, GlIcon, GlAlert, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlAlert,
+ GlLoadingIcon,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import { produce } from 'immer';
import { s__ } from '~/locale';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -9,7 +17,12 @@ import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_detail
import { isMetaKey } from '~/lib/utils/common_utils';
import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
-import { WIDGET_ICONS, WORK_ITEM_STATUS_TEXT, WIDGET_TYPE_HIERARCHY } from '../../constants';
+import {
+ FORM_TYPES,
+ WIDGET_ICONS,
+ WORK_ITEM_STATUS_TEXT,
+ WIDGET_TYPE_HIERARCHY,
+} from '../../constants';
import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
import workItemQuery from '../../graphql/work_item.query.graphql';
@@ -20,6 +33,8 @@ import WorkItemLinksForm from './work_item_links_form.vue';
export default {
components: {
GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
GlAlert,
GlLoadingIcon,
@@ -80,6 +95,7 @@ export default {
prefetchedWorkItem: null,
error: undefined,
parentIssue: null,
+ formType: null,
};
},
computed: {
@@ -89,6 +105,9 @@ export default {
issuableIteration() {
return this.parentIssue?.iteration;
},
+ issuableMilestone() {
+ return this.parentIssue?.milestone;
+ },
children() {
return (
this.workItem?.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children
@@ -125,9 +144,10 @@ export default {
toggle() {
this.isOpen = !this.isOpen;
},
- showAddForm() {
+ showAddForm(formType) {
this.isOpen = true;
this.isShownAddForm = true;
+ this.formType = formType;
this.$nextTick(() => {
this.$refs.wiLinksForm.$refs.wiTitleInput?.$el.focus();
});
@@ -239,15 +259,18 @@ export default {
'WorkItem|No tasks are currently assigned. Use tasks to break down this issue into smaller parts.',
),
addChildButtonLabel: s__('WorkItem|Add'),
+ addChildOptionLabel: s__('WorkItem|Existing task'),
+ createChildOptionLabel: s__('WorkItem|New task'),
},
WIDGET_TYPE_TASK_ICON: WIDGET_ICONS.TASK,
WORK_ITEM_STATUS_TEXT,
+ FORM_TYPES,
};
</script>
<template>
<div
- class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-5"
+ class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4"
data-testid="work-item-links"
>
<div
@@ -264,15 +287,26 @@ export default {
{{ childrenCountLabel }}
</span>
</div>
- <gl-button
+ <gl-dropdown
v-if="canUpdate"
- category="secondary"
+ right
size="small"
- data-testid="toggle-add-form"
- @click="showAddForm"
+ :text="$options.i18n.addChildButtonLabel"
+ data-testid="toggle-form"
>
- {{ $options.i18n.addChildButtonLabel }}
- </gl-button>
+ <gl-dropdown-item
+ data-testid="toggle-create-form"
+ @click="showAddForm($options.FORM_TYPES.create)"
+ >
+ {{ $options.i18n.createChildOptionLabel }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ data-testid="toggle-add-form"
+ @click="showAddForm($options.FORM_TYPES.add)"
+ >
+ {{ $options.i18n.addChildOptionLabel }}
+ </gl-dropdown-item>
+ </gl-dropdown>
<div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3">
<gl-button
category="tertiary"
@@ -309,6 +343,8 @@ export default {
:children-ids="childrenIds"
:parent-confidential="confidential"
:parent-iteration="issuableIteration"
+ :parent-milestone="issuableMilestone"
+ :form-type="formType"
@cancel="hideAddForm"
@addWorkItemChild="addChild"
/>
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 a01f4616cab..095ea86e0d8 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
@@ -1,21 +1,26 @@
<script>
-import { GlAlert, GlFormGroup, GlForm, GlFormCombobox, GlButton, GlFormInput } from '@gitlab/ui';
+import { GlAlert, GlFormGroup, GlForm, GlTokenSelector, GlButton, GlFormInput } from '@gitlab/ui';
+import { debounce } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __, s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
+import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql';
-import { TASK_TYPE_NAME } from '../../constants';
+import { FORM_TYPES, TASK_TYPE_NAME } from '../../constants';
export default {
components: {
GlAlert,
GlForm,
- GlFormCombobox,
+ GlTokenSelector,
GlButton,
GlFormGroup,
GlFormInput,
},
+ mixins: [glFeatureFlagMixin()],
inject: ['projectPath', 'hasIterationsFeature'],
props: {
issuableGid: {
@@ -38,6 +43,15 @@ export default {
required: false,
default: () => {},
},
+ parentMilestone: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ formType: {
+ type: String,
+ required: true,
+ },
},
apollo: {
workItemTypes: {
@@ -51,33 +65,73 @@ export default {
return data.workspace?.workItemTypes?.nodes;
},
},
+ availableWorkItems: {
+ query: projectWorkItemsQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ searchTerm: this.search?.title || this.search,
+ types: ['TASK'],
+ in: this.search ? 'TITLE' : undefined,
+ };
+ },
+ skip() {
+ return !this.searchStarted;
+ },
+ update(data) {
+ return data.workspace.workItems.nodes.filter((wi) => !this.childrenIds.includes(wi.id));
+ },
+ },
},
data() {
return {
+ workItemTypes: [],
availableWorkItems: [],
search: '',
+ searchStarted: false,
error: null,
childToCreateTitle: null,
+ workItemsToAdd: [],
};
},
computed: {
- actionsList() {
- return [
- {
- label: this.$options.i18n.createChildOptionLabel,
- fn: () => {
- this.childToCreateTitle = this.search?.title || this.search;
- },
+ workItemInput() {
+ let workItemInput = {
+ title: this.search?.title || this.search,
+ projectPath: this.projectPath,
+ workItemTypeId: this.taskWorkItemType,
+ hierarchyWidget: {
+ parentId: this.issuableGid,
},
- ];
+ confidential: this.parentConfidential,
+ };
+
+ if (this.associateMilestone) {
+ workItemInput = {
+ ...workItemInput,
+ milestoneWidget: {
+ milestoneId: this.parentMilestoneId,
+ },
+ };
+ }
+ return workItemInput;
+ },
+ workItemsMvc2Enabled() {
+ return this.glFeatures.workItemsMvc2;
+ },
+ isCreateForm() {
+ return this.formType === FORM_TYPES.create;
},
addOrCreateButtonLabel() {
- return this.childToCreateTitle
- ? this.$options.i18n.createChildOptionLabel
- : this.$options.i18n.addTaskButtonLabel;
+ if (this.isCreateForm) {
+ return this.$options.i18n.createChildOptionLabel;
+ } else if (this.workItemsToAdd.length > 1) {
+ return this.$options.i18n.addTasksButtonLabel;
+ }
+ return this.$options.i18n.addTaskButtonLabel;
},
addOrCreateMethod() {
- return this.childToCreateTitle ? this.createChild : this.addChild;
+ return this.isCreateForm ? this.createChild : this.addChild;
},
taskWorkItemType() {
return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id;
@@ -85,6 +139,24 @@ export default {
parentIterationId() {
return this.parentIteration?.id;
},
+ associateIteration() {
+ return this.parentIterationId && this.hasIterationsFeature && this.workItemsMvc2Enabled;
+ },
+ parentMilestoneId() {
+ return this.parentMilestone?.id;
+ },
+ associateMilestone() {
+ return this.parentMilestoneId && this.workItemsMvc2Enabled;
+ },
+ isSubmitButtonDisabled() {
+ return this.isCreateForm ? this.search.length === 0 : this.workItemsToAdd.length === 0;
+ },
+ isLoading() {
+ return this.$apollo.queries.availableWorkItems.loading;
+ },
+ },
+ created() {
+ this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
methods: {
getIdFromGraphQLId,
@@ -92,6 +164,7 @@ export default {
this.error = null;
},
addChild() {
+ this.searchStarted = false;
this.$apollo
.mutate({
mutation: updateWorkItemMutation,
@@ -99,7 +172,7 @@ export default {
input: {
id: this.issuableGid,
hierarchyWidget: {
- childrenIds: [this.search.id],
+ childrenIds: this.workItemsToAdd.map((wi) => wi.id),
},
},
},
@@ -109,7 +182,7 @@ export default {
[this.error] = data.workItemUpdate.errors;
} else {
this.unsetError();
- this.$emit('addWorkItemChild', this.search);
+ this.workItemsToAdd = [];
}
})
.catch(() => {
@@ -124,15 +197,7 @@ export default {
.mutate({
mutation: createWorkItemMutation,
variables: {
- input: {
- title: this.search?.title || this.search,
- projectPath: this.projectPath,
- workItemTypeId: this.taskWorkItemType,
- hierarchyWidget: {
- parentId: this.issuableGid,
- },
- confidential: this.parentConfidential,
- },
+ input: this.workItemInput,
},
})
.then(({ data }) => {
@@ -145,7 +210,7 @@ export default {
* call update mutation only when there is an iteration associated with the issue
*/
// TODO: setting the iteration should be moved to the creation mutation once the backend is done
- if (this.parentIterationId && this.hasIterationsFeature) {
+ if (this.associateIteration) {
this.addIterationToWorkItem(data.workItemCreate.workItem.id);
}
}
@@ -171,10 +236,25 @@ export default {
},
});
},
+ setSearchKey(value) {
+ this.search = value;
+ },
+ handleFocus() {
+ this.searchStarted = true;
+ },
+ handleMouseOver() {
+ this.timeout = setTimeout(() => {
+ this.searchStarted = true;
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ },
+ handleMouseOut() {
+ clearTimeout(this.timeout);
+ },
},
i18n: {
inputLabel: __('Title'),
addTaskButtonLabel: s__('WorkItem|Add task'),
+ addTasksButtonLabel: s__('WorkItem|Add tasks'),
addChildErrorMessage: s__(
'WorkItem|Something went wrong when trying to add a child. Please try again.',
),
@@ -182,7 +262,8 @@ export default {
createChildErrorMessage: s__(
'WorkItem|Something went wrong when trying to create a child. Please try again.',
),
- placeholder: s__('WorkItem|Add a title'),
+ createPlaceholder: s__('WorkItem|Add a title'),
+ addPlaceholder: s__('WorkItem|Search existing tasks'),
fieldValidationMessage: __('Maximum of 255 characters'),
},
};
@@ -191,56 +272,59 @@ export default {
<template>
<gl-form
class="gl-bg-white gl-mb-3 gl-p-4 gl-border gl-border-gray-100 gl-rounded-base"
- @submit.prevent="createChild"
+ @submit.prevent="addOrCreateMethod"
>
<gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError">
{{ error }}
</gl-alert>
- <!-- Follow up issue to turn this functionality back on https://gitlab.com/gitlab-org/gitlab/-/issues/368757 -->
- <gl-form-combobox
- v-if="false"
- v-model="search"
- :token-list="availableWorkItems"
- match-value-to-attr="title"
- class="gl-mb-4"
- :label-text="$options.i18n.inputLabel"
- :action-list="actionsList"
- label-sr-only
- autofocus
- >
- <template #result="{ item }">
- <div class="gl-display-flex">
- <div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(item.id) }}</div>
- <div>{{ item.title }}</div>
- </div>
- </template>
- <template #action="{ item }">
- <span class="gl-text-blue-500">{{ item.label }}</span>
- </template>
- </gl-form-combobox>
<gl-form-group
+ v-if="isCreateForm"
:label="$options.i18n.inputLabel"
:description="$options.i18n.fieldValidationMessage"
>
<gl-form-input
ref="wiTitleInput"
v-model="search"
- :placeholder="$options.i18n.placeholder"
+ :placeholder="$options.i18n.createPlaceholder"
maxlength="255"
class="gl-mb-3"
autofocus
/>
</gl-form-group>
+ <gl-token-selector
+ v-else
+ v-model="workItemsToAdd"
+ :dropdown-items="availableWorkItems"
+ :loading="isLoading"
+ :placeholder="$options.i18n.addPlaceholder"
+ menu-class="gl-dropdown-menu-wide dropdown-reduced-height gl-min-h-7!"
+ class="gl-mb-4"
+ data-testid="work-item-token-select-input"
+ @text-input="debouncedSearchKeyUpdate"
+ @focus="handleFocus"
+ @mouseover.native="handleMouseOver"
+ @mouseout.native="handleMouseOut"
+ >
+ <template #token-content="{ token }">
+ {{ token.title }}
+ </template>
+ <template #dropdown-item-content="{ dropdownItem }">
+ <div class="gl-display-flex">
+ <div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(dropdownItem.id) }}</div>
+ <div class="gl-text-truncate">{{ dropdownItem.title }}</div>
+ </div>
+ </template>
+ </gl-token-selector>
<gl-button
category="primary"
variant="confirm"
size="small"
type="submit"
- :disabled="search.length === 0"
+ :disabled="isSubmitButtonDisabled"
data-testid="add-child-button"
class="gl-mr-2"
>
- {{ $options.i18n.createChildOptionLabel }}
+ {{ addOrCreateButtonLabel }}
</gl-button>
<gl-button category="secondary" size="small" @click="$emit('cancel')">
{{ s__('WorkItem|Cancel') }}
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 c4a36e36555..a8d3b57aae0 100644
--- a/app/assets/javascripts/work_items/components/work_item_milestone.vue
+++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue
@@ -11,10 +11,10 @@ import {
import * as Sentry from '@sentry/browser';
import { debounce } from 'lodash';
import Tracking from '~/tracking';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql';
-import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import {
I18N_WORK_ITEM_ERROR_UPDATING,
sprintfWorkItem,
@@ -33,6 +33,7 @@ export default {
MILESTONE_FETCH_ERROR: s__(
'WorkItem|Something went wrong while fetching milestones. Please try again.',
),
+ EXPIRED_TEXT: __('(expired)'),
},
components: {
GlFormGroup,
@@ -68,6 +69,15 @@ export default {
type: String,
required: true,
},
+ fetchByIid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -90,8 +100,13 @@ export default {
emptyPlaceholder() {
return this.canUpdate ? this.$options.i18n.MILESTONE_PLACEHOLDER : this.$options.i18n.NONE;
},
+ expired() {
+ return this.localMilestone?.expired ? ` ${this.$options.i18n.EXPIRED_TEXT}` : '';
+ },
dropdownText() {
- return this.localMilestone?.title || this.emptyPlaceholder;
+ return this.localMilestone?.title
+ ? `${this.localMilestone?.title}${this.expired}`
+ : this.emptyPlaceholder;
},
isLoadingMilestones() {
return this.$apollo.queries.milestones.loading;
@@ -106,6 +121,14 @@ export default {
};
},
},
+ watch: {
+ workItemMilestone: {
+ handler(newVal) {
+ this.localMilestone = newVal;
+ },
+ deep: true,
+ },
+ },
created() {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
@@ -160,12 +183,13 @@ export default {
this.updateInProgress = true;
this.$apollo
.mutate({
- mutation: localUpdateWorkItemMutation,
+ mutation: updateWorkItemMutation,
variables: {
input: {
id: this.workItemId,
- milestone: {
- milestoneId: this.localMilestone?.id,
+ milestoneWidget: {
+ milestoneId:
+ this.localMilestone?.id === 'no-milestone-id' ? null : this.localMilestone?.id,
},
},
},
@@ -240,6 +264,7 @@ export default {
@click="handleMilestoneClick(milestone)"
>
{{ milestone.title }}
+ <template v-if="milestone.expired">{{ $options.i18n.EXPIRED_TEXT }}</template>
</gl-dropdown-item>
</template>
<gl-dropdown-text v-else>{{ $options.i18n.NO_MATCHING_RESULTS }}</gl-dropdown-text>
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index 7737c535650..8b47c24de7d 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -102,4 +102,9 @@ export const WORK_ITEMS_TYPE_MAP = {
},
};
+export const FORM_TYPES = {
+ create: 'create',
+ add: 'add',
+};
+
export const DEFAULT_PAGE_SIZE_ASSIGNEES = 10;
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 6edb6c89f16..daeb58c0947 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
@@ -4,6 +4,9 @@ query issuableDetails($fullPath: ID!, $iid: String) {
issuable: issue(iid: $iid) {
id
confidential
+ milestone {
+ id
+ }
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql b/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql
new file mode 100644
index 00000000000..58140aff89e
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql
@@ -0,0 +1,5 @@
+fragment MilestoneFragment on Milestone {
+ expired
+ id
+ title
+}
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 7d38d203b84..3a23db3886a 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,13 +1,16 @@
-query projectWorkItems($searchTerm: String, $projectPath: ID!, $types: [IssueType!]) {
+query projectWorkItems(
+ $searchTerm: String
+ $projectPath: ID!
+ $types: [IssueType!]
+ $in: [IssuableSearchableField!]
+) {
workspace: project(fullPath: $projectPath) {
id
- workItems(search: $searchTerm, types: $types) {
- edges {
- node {
- id
- title
- state
- }
+ workItems(search: $searchTerm, types: $types, in: $in) {
+ nodes {
+ id
+ title
+ state
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
index 36779dfe11e..fda71fabe22 100644
--- a/app/assets/javascripts/work_items/graphql/typedefs.graphql
+++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql
@@ -1,6 +1,5 @@
enum LocalWidgetType {
ASSIGNEES
- MILESTONE
}
interface LocalWorkItemWidget {
@@ -12,11 +11,6 @@ type LocalWorkItemAssignees implements LocalWorkItemWidget {
nodes: [UserCore]
}
-type LocalWorkItemMilestone implements LocalWorkItemWidget {
- type: LocalWidgetType!
- nodes: [Milestone!]
-}
-
extend type WorkItem {
mockWidgets: [LocalWorkItemWidget]
}
@@ -29,14 +23,9 @@ input LocalUserInput {
avatarUrl: String
}
-input LocalMilestoneInput {
- milestoneId: ID!
-}
-
input LocalUpdateWorkItemInput {
id: WorkItemID!
assignees: [LocalUserInput!]
- milestone: LocalMilestoneInput!
}
type LocalWorkItemPayload {
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 bb05c9b2135..6a81cc230b1 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -2,6 +2,7 @@
fragment WorkItem on WorkItem {
id
+ iid
title
state
description
diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
index fa0ab56df75..3b46fed97ec 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
@@ -3,16 +3,5 @@
query workItem($id: WorkItemID!) {
workItem(id: $id) {
...WorkItem
- mockWidgets @client {
- ... on LocalWorkItemMilestone {
- type
- nodes {
- id
- title
- expired
- dueDate
- }
- }
- }
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql
new file mode 100644
index 00000000000..4c3be007d96
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql
@@ -0,0 +1,12 @@
+#import "./work_item.fragment.graphql"
+
+query workItemByIid($fullPath: ID!, $iid: String) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ workItems(iid: $iid) {
+ nodes {
+ ...WorkItem
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_description.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_description.subscription.graphql
new file mode 100644
index 00000000000..4eb3d8067d9
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/work_item_description.subscription.graphql
@@ -0,0 +1,20 @@
+subscription issuableDescription($issuableId: IssuableID!) {
+ issuableDescriptionUpdated(issuableId: $issuableId) {
+ ... on WorkItem {
+ id
+ widgets {
+ ... on WorkItemWidgetDescription {
+ type
+ description
+ descriptionHtml
+ lastEditedAt
+ lastEditedBy {
+ id
+ name
+ webPath
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_milestone.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_milestone.subscription.graphql
new file mode 100644
index 00000000000..f5163003fe5
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/work_item_milestone.subscription.graphql
@@ -0,0 +1,17 @@
+#import "~/work_items/graphql/milestone.fragment.graphql"
+
+subscription issuableMilestone($issuableId: IssuableID!) {
+ issuableMilestoneUpdated(issuableId: $issuableId) {
+ ... on WorkItem {
+ id
+ widgets {
+ ... on WorkItemWidgetMilestone {
+ type
+ milestone {
+ ...MilestoneFragment
+ }
+ }
+ }
+ }
+ }
+}
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 d404cfb10ed..b9715c21c27 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,5 +1,6 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/work_items/graphql/milestone.fragment.graphql"
fragment WorkItemWidgets on WorkItemWidget {
... on WorkItemWidgetDescription {
@@ -49,4 +50,10 @@ fragment WorkItemWidgets on WorkItemWidget {
}
}
}
+ ... on WorkItemWidgetMilestone {
+ type
+ milestone {
+ ...MilestoneFragment
+ }
+ }
}
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index f872d8c6b12..4fbcdfe2b96 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -6,13 +6,7 @@ import { createRouter } from './router';
export const initWorkItemsRoot = () => {
const el = document.querySelector('#js-work-items');
- const {
- fullPath,
- hasIssueWeightsFeature,
- issuesListPath,
- projectNamespace,
- hasIterationsFeature,
- } = el.dataset;
+ const { fullPath, hasIssueWeightsFeature, issuesListPath, hasIterationsFeature } = el.dataset;
return new Vue({
el,
@@ -23,7 +17,6 @@ export const initWorkItemsRoot = () => {
fullPath,
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
issuesListPath,
- projectNamespace,
hasIterationsFeature: parseBoolean(hasIterationsFeature),
},
render(createElement) {
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 4908b99e5b0..2245f984174 100644
--- a/app/assets/javascripts/work_items/pages/create_work_item.vue
+++ b/app/assets/javascripts/work_items/pages/create_work_item.vue
@@ -3,10 +3,11 @@ import { GlButton, GlAlert, GlLoadingIcon, GlFormSelect } from '@gitlab/ui';
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 workItemQuery from '../graphql/work_item.query.graphql';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql';
+import { getWorkItemQuery } from '../utils';
import ItemTitle from '../components/item_title.vue';
@@ -21,6 +22,7 @@ export default {
ItemTitle,
GlFormSelect,
},
+ mixins: [glFeatureFlagMixin()],
inject: ['fullPath'],
props: {
initialTitle: {
@@ -71,6 +73,9 @@ export default {
return sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemType);
},
+ fetchByIid() {
+ return this.glFeatures.useIidInWorkItemsPath;
+ },
},
methods: {
async createWorkItem() {
@@ -89,28 +94,47 @@ export default {
workItemTypeId: this.selectedWorkItemType,
},
},
- update(store, { data: { workItemCreate } }) {
+ 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: workItemQuery,
- variables: {
- id: workItem.id,
- },
- data: {
- workItem,
- },
+ query: getWorkItemQuery(this.fetchByIid),
+ variables: this.fetchByIid
+ ? {
+ fullPath: this.fullPath,
+ iid: workItem.iid,
+ }
+ : {
+ id: workItem.id,
+ },
+ data,
});
},
});
const {
data: {
workItemCreate: {
- workItem: { id },
+ workItem: { id, iid },
},
},
} = response;
- this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } });
+ const routerParams = this.fetchByIid
+ ? { name: 'workItem', params: { id: iid }, query: { iid_path: 'true' } }
+ : { name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } };
+ this.$router.push(routerParams);
} catch {
this.error = this.createErrorText;
}
diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue
index a2cacd8bd7a..1c00bd16263 100644
--- a/app/assets/javascripts/work_items/pages/work_item_root.vue
+++ b/app/assets/javascripts/work_items/pages/work_item_root.vue
@@ -38,14 +38,12 @@ export default {
this.ZenMode = new ZenMode();
},
methods: {
- deleteWorkItem(workItemType) {
+ deleteWorkItem({ workItemType, workItemId: id }) {
this.$apollo
.mutate({
mutation: deleteWorkItemMutation,
variables: {
- input: {
- id: this.gid,
- },
+ input: { id },
},
})
.then(({ data: { workItemDelete, errors } }) => {
@@ -72,6 +70,6 @@ export default {
<template>
<div>
<gl-alert v-if="error" variant="danger" @dismiss="error = ''">{{ error }}</gl-alert>
- <work-item-detail :work-item-id="gid" @deleteWorkItem="deleteWorkItem($event)" />
+ <work-item-detail :work-item-id="gid" :iid="id" @deleteWorkItem="deleteWorkItem($event)" />
</div>
</template>
diff --git a/app/assets/javascripts/work_items/router/index.js b/app/assets/javascripts/work_items/router/index.js
index 2b39a298720..777badeb5be 100644
--- a/app/assets/javascripts/work_items/router/index.js
+++ b/app/assets/javascripts/work_items/router/index.js
@@ -9,7 +9,7 @@ Vue.use(VueRouter);
export function createRouter(fullPath) {
return new VueRouter({
- routes,
+ routes: routes(),
mode: 'history',
base: joinPaths(fullPath, '-', 'work_items'),
});
diff --git a/app/assets/javascripts/work_items/router/routes.js b/app/assets/javascripts/work_items/router/routes.js
index 95772bbd026..1e3a7e184bb 100644
--- a/app/assets/javascripts/work_items/router/routes.js
+++ b/app/assets/javascripts/work_items/router/routes.js
@@ -1,13 +1,22 @@
-export const routes = [
- {
- path: '/new',
- name: 'createWorkItem',
- component: () => import('../pages/create_work_item.vue'),
- },
- {
- path: '/:id',
- name: 'workItem',
- component: () => import('../pages/work_item_root.vue'),
- props: true,
- },
-];
+function getRoutes() {
+ const routes = [
+ {
+ path: '/:id',
+ name: 'workItem',
+ component: () => import('../pages/work_item_root.vue'),
+ props: true,
+ },
+ ];
+
+ if (gon.features?.workItemsMvc2) {
+ routes.unshift({
+ path: '/new',
+ name: 'createWorkItem',
+ component: () => import('../pages/create_work_item.vue'),
+ });
+ }
+
+ return routes;
+}
+
+export const routes = getRoutes;
diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js
new file mode 100644
index 00000000000..17f9c882c2d
--- /dev/null
+++ b/app/assets/javascripts/work_items/utils.js
@@ -0,0 +1,6 @@
+import workItemQuery from './graphql/work_item.query.graphql';
+import workItemByIidQuery from './graphql/work_item_by_iid.query.graphql';
+
+export function getWorkItemQuery(isFetchedByIid) {
+ return isFetchedByIid ? workItemByIidQuery : workItemQuery;
+}
diff --git a/app/assets/javascripts/work_items_hierarchy/components/app.vue b/app/assets/javascripts/work_items_hierarchy/components/app.vue
index 779bd27516a..1eeb3abf4bd 100644
--- a/app/assets/javascripts/work_items_hierarchy/components/app.vue
+++ b/app/assets/javascripts/work_items_hierarchy/components/app.vue
@@ -25,7 +25,7 @@ export default {
workItemTypes() {
return this.workItemHierarchy.reduce(
(itemTypes, item) => {
- const skipItem = workItemTypes[item.type].isWorkItem && !window.gon?.features?.workItems;
+ const skipItem = workItemTypes[item.type].isWorkItem;
if (skipItem) {
return itemTypes;
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index 21d9db26382..6878e9a10d7 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -1,4 +1,3 @@
-@import './pages/branches';
@import './pages/colors';
@import './pages/commits';
@import './pages/detail_page';
@@ -9,11 +8,11 @@
@import './pages/issues';
@import './pages/labels';
@import './pages/login';
+@import './pages/ml_experiment_tracking';
@import './pages/merge_requests';
@import './pages/monitor';
@import './pages/note_form';
@import './pages/notes';
-@import './pages/notifications';
@import './pages/pipelines';
@import './pages/profile';
@import './pages/projects';
diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss
index e69d7b4462d..27e9a041145 100644
--- a/app/assets/stylesheets/framework/calendar.scss
+++ b/app/assets/stylesheets/framework/calendar.scss
@@ -1,6 +1,4 @@
.user-contrib-cell {
- stroke: $t-gray-a-08;
-
&:hover {
cursor: pointer;
stroke: $black;
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index d561a7d9450..c5a34ca4b31 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -46,6 +46,10 @@
}
}
+ &.dropdown-reduced-height {
+ max-height: $dropdown-max-height;
+ }
+
@include media-breakpoint-down(xs) {
width: 100%;
}
diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss
index a31910e3090..68a3493670d 100644
--- a/app/assets/stylesheets/framework/emojis.scss
+++ b/app/assets/stylesheets/framework/emojis.scss
@@ -8,6 +8,7 @@ gl-emoji {
}
.user-status-emoji {
+ margin-left: $gl-padding-4;
margin-right: $gl-padding-4;
gl-emoji {
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 07516275e58..b28302f29ef 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -290,6 +290,10 @@
padding: $gl-padding;
}
}
+
+ .content-visibility-auto {
+ content-visibility: auto;
+ }
}
}
diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss
index 02b76b89482..7a92adf7b7b 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -207,7 +207,7 @@ body {
}
@include media-breakpoint-up(sm) {
- .logged-out-marketing-header-candidate {
+ .logged-out-marketing-header {
--header-height: 72px;
}
}
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index 900cf9fa4db..ea741af918c 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -1,15 +1,3 @@
-.ajax-users-select {
- width: 400px;
-
- &.input-large {
- width: 210px;
- }
-
- &.input-clamp {
- max-width: 100%;
- }
-}
-
.group-result {
.group-image {
float: left;
@@ -49,7 +37,3 @@
}
}
}
-
-.ajax-users-dropdown {
- min-width: 250px !important;
-}
diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss
index 39786aa0138..14971e3b2ee 100644
--- a/app/assets/stylesheets/framework/snippets.scss
+++ b/app/assets/stylesheets/framework/snippets.scss
@@ -24,6 +24,10 @@
+ .snippet-file-content {
@include gl-mt-5;
}
+
+ &:last-of-type {
+ margin-bottom: 0;
+ }
}
.snippet-header {
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 9cfc5a0201e..99284ea0a64 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -88,14 +88,6 @@ $white-normal: #f0f0f0 !default;
$white-dark: #eaeaea !default;
$white-transparent: rgba($white, 0.8) !default;
-$gray-lightest: #fdfdfd !default;
-$gray-light: #fafafa !default;
-$gray-lighter: #f9f9f9 !default;
-$gray-normal: #f5f5f5 !default;
-$gray-dark: darken($gray-light, $darken-dark-factor) !default;
-$gray-darker: #eee !default;
-$gray-darkest: #c4c4c4 !default;
-
$purple: #6d49cb !default;
$purple-light: #ede8fb !default;
@@ -103,11 +95,6 @@ $black: #000 !default;
$black-transparent: rgba(0, 0, 0, 0.3) !default;
$almost-black: #242424 !default;
-$t-gray-a-02: rgba($black, 0.02) !default;
-$t-gray-a-04: rgba($black, 0.04) !default;
-$t-gray-a-06: rgba($black, 0.06) !default;
-$t-gray-a-08: rgba($black, 0.08) !default;
-
$green-50: #ecf4ee !default;
$green-100: #c3e6cd !default;
$green-200: #91d4a8 !default;
@@ -168,18 +155,33 @@ $purple-800: #453894 !default;
$purple-900: #2f2a6b !default;
$purple-950: #232150 !default;
-$gray-10: #f5f5f5 !default;
-$gray-50: #f0f0f0 !default;
-$gray-100: #dbdbdb !default;
-$gray-200: #bfbfbf !default;
-$gray-300: #999 !default;
-$gray-400: #868686 !default;
-$gray-500: #666 !default;
-$gray-600: #5e5e5e !default;
-$gray-700: #525252 !default;
-$gray-800: #404040 !default;
-$gray-900: #303030 !default;
-$gray-950: #1f1f1f !default;
+$gray-10: #fbfafd !default;
+$gray-50: #ececef !default;
+$gray-100: #dcdcde !default;
+$gray-200: #bfbfc3 !default;
+$gray-300: #a4a3a8 !default;
+$gray-400: #89888d !default;
+$gray-500: #737278 !default;
+$gray-600: #626168 !default;
+$gray-700: #535158 !default;
+$gray-800: #434248 !default;
+$gray-900: #333238 !default;
+$gray-950: #1f1e24 !default;
+
+$gray-lightest: lighten($gray-10, 1) !default;
+$gray-light: $gray-10 !default;
+$gray-lighter: lighten($gray-50, 4) !default;
+$gray-normal: lighten($gray-50, 2) !default;
+$gray-dark: darken($gray-light, $darken-dark-factor) !default;
+$gray-darker: $gray-50 !default;
+$gray-darkest: $gray-200 !default;
+
+$t-gray-a-02: rgba($gray-950, 0.02) !default;
+$t-gray-a-04: rgba($gray-950, 0.04) !default;
+$t-gray-a-06: rgba($gray-950, 0.06) !default;
+$t-gray-a-08: rgba($gray-950, 0.08) !default;
+$t-gray-a-16: rgba($gray-950, 0.16) !default;
+$t-gray-a-24: rgba($gray-950, 0.24) !default;
$greens: (
'50': $green-50,
@@ -346,6 +348,20 @@ $theme-light-red-500: #c24b38;
$theme-light-red-600: #b03927;
$theme-light-red-700: #a62e21;
+// Data visualization color palette
+
+$data-viz-blue-50: #e9ebff;
+$data-viz-blue-100: #d4dcfa;
+$data-viz-blue-200: #b7c6ff;
+$data-viz-blue-300: #97acff;
+$data-viz-blue-400: #748eff;
+$data-viz-blue-500: #5772ff;
+$data-viz-blue-600: #445cf2;
+$data-viz-blue-700: #3547de;
+$data-viz-blue-800: #232fcf;
+$data-viz-blue-900: #1e23a8;
+$data-viz-blue-950: #11118a;
+
$border-white-light: darken($white, $darken-border-factor) !default;
$border-white-normal: darken($white-normal, $darken-border-factor) !default;
@@ -356,7 +372,7 @@ $border-gray-normal-dashed: darken($gray-normal, $darken-border-dashed-factor);
/*
* UI elements
*/
-$contextual-sidebar-bg-color: #f5f5f5;
+$contextual-sidebar-bg-color: $gray-10;
$contextual-sidebar-border-color: #e9e9e9;
$border-color: $gray-100;
$shadow-color: $t-gray-a-08;
@@ -660,18 +676,7 @@ $ci-skipped-color: #888;
*/
$issue-boards-font-size: 14px;
$issue-boards-card-shadow: rgba(0, 0, 0, 0.1);
-/*
- The following heights are used in boards.scss and are used for calculation of the board height.
- They probably should be derived in a smarter way.
-*/
$issue-boards-filter-height: 68px;
-$issue-boards-filter-height-md: 110px;
-$issue-boards-filter-height-sm: 299px;
-$issue-boards-breadcrumbs-height-xs: 63px;
-$issue-board-list-difference-xs: calc(#{$header-height} + #{$issue-boards-breadcrumbs-height-xs});
-$issue-board-list-difference-sm: calc(#{$header-height} + #{$breadcrumb-min-height});
-$issue-board-list-difference-md: calc(#{$issue-board-list-difference-sm} + #{$issue-boards-filter-height-md});
-$issue-board-list-difference-lg: calc(#{$issue-board-list-difference-sm} + #{$issue-boards-filter-height});
/*
The following heights are used in environment_logs.scss and are used for calculation of the log viewer height.
*/
@@ -710,11 +715,11 @@ $job-arrow-margin: 55px;
*/
// See https://gitlab.com/gitlab-org/gitlab/-/issues/332150 to align with Pajamas Design System
$calendar-activity-colors: (
- #f5f5f5,
- #d4dcfa,
- #748eff,
- #3547de,
- #11118a,
+ $gray-50,
+ $data-viz-blue-100,
+ $data-viz-blue-400,
+ $data-viz-blue-700,
+ $data-viz-blue-950,
) !default;
/*
@@ -756,12 +761,6 @@ $document-index-color: #888;
$help-shortcut-header-color: #333;
/*
-* Issues
-*/
-$issues-today-bg: #f3fff2 !default;
-$issues-today-border: #e1e8d5 !default;
-
-/*
* Label
*/
$label-font-size: 12px;
diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index 7fb2bf9a875..3438a73eff6 100644
--- a/app/assets/stylesheets/highlight/themes/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -125,6 +125,14 @@ $dark-il: #de935f;
}
.code.dark {
+ // Highlight.js theme overrides (https://gitlab.com/gitlab-org/gitlab/-/issues/365167)
+ // We should be able to remove the overrides once the upstream issue is fixed (https://github.com/sourcegraph/sourcegraph/issues/23251)
+ @include hljs-override('title\\.class', $dark-nc);
+ @include hljs-override('title\\.class\\.inherited', $dark-no);
+ @include hljs-override('variable\\.constant', $dark-no);
+ @include hljs-override('title\\.function', $dark-nf);
+
+
// Line numbers
.file-line-num {
@include line-link($white, 'link');
diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index 66cada9181c..75dd342393d 100644
--- a/app/assets/stylesheets/highlight/themes/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -104,7 +104,7 @@ $monokai-gh: #75715e;
@include hljs-override('selector-tag', $monokai-nt);
@include hljs-override('keyword', $monokai-k);
@include hljs-override('variable', $monokai-nv);
- @include hljs-override('variable.language_', $monokai-k);
+ @include hljs-override('variable\\.language_', $monokai-k);
@include hljs-override('title', $monokai-nf);
@include hljs-override('name', $monokai-k);
@include hljs-override('tag', $monokai-nt);
@@ -116,7 +116,13 @@ $monokai-gh: #75715e;
@include hljs-override('bullet', $monokai-n);
@include hljs-override('subst', $monokai-p);
@include hljs-override('symbol', $monokai-ss);
- @include hljs-override('title.class_.inherited__', $monokai-no);
+ @include hljs-override('title\\.class_\\.inherited__', $monokai-no);
+ @include hljs-override('title\\.class\\.inherited', $monokai-no);
+ @include hljs-override('title\\.class', $monokai-nc);
+ @include hljs-override('title\\.function', $monokai-nf);
+ @include hljs-override('variable\\.constant', $monokai-no);
+ @include hljs-override('variable\\.language', $monokai-nb);
+ @include hljs-override('params', $monokai-nb);
// Line numbers
.file-line-num {
diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
index a1bba8720a2..c0b2fb90aa0 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
@@ -107,7 +107,9 @@ $solarized-dark-il: #2aa198;
@include hljs-override('selector-tag', $solarized-dark-nt);
@include hljs-override('keyword', $solarized-dark-k);
@include hljs-override('variable', $solarized-dark-nv);
- @include hljs-override('variable.language_', $solarized-dark-k);
+ @include hljs-override('variable\\.language_', $solarized-dark-k);
+ @include hljs-override('variable\\.language', $solarized-dark-k);
+ @include hljs-override('variable\\.constant', $solarized-dark-no);
@include hljs-override('title', $solarized-dark-nf);
@include hljs-override('name', $solarized-dark-k);
@include hljs-override('tag', $solarized-dark-nt);
@@ -119,7 +121,11 @@ $solarized-dark-il: #2aa198;
@include hljs-override('bullet', $solarized-dark-n);
@include hljs-override('subst', $solarized-dark-p);
@include hljs-override('symbol', $solarized-dark-ni);
- @include hljs-override('title.class_.inherited__', $solarized-dark-no);
+ @include hljs-override('title\\.class_\\.inherited__', $solarized-dark-no);
+ @include hljs-override('title\\.class', $solarized-dark-nc);
+ @include hljs-override('title\\.class\\.inherited', $solarized-dark-no);
+ @include hljs-override('title\\.function', $solarized-dark-nf);
+ @include hljs-override('params', $solarized-dark-nb);
// Line numbers
.file-line-num {
diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss
index 33945f7cda9..921b36dd610 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-light.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss
@@ -106,7 +106,17 @@ $solarized-light-il: #2aa198;
}
.code.solarized-light {
- @include hljs-override('title.class_.inherited__', $solarized-light-no);
+ // Highlight.js theme overrides (https://gitlab.com/gitlab-org/gitlab/-/issues/365167)
+ // We should be able to remove the overrides once the upstream issue is fixed (https://github.com/sourcegraph/sourcegraph/issues/23251)
+ @include hljs-override('keyword', $solarized-light-k);
+ @include hljs-override('title\\.class_\\.inherited__', $solarized-light-no);
+ @include hljs-override('title\\.class\\.inherited', $solarized-light-no);
+ @include hljs-override('title\\.class', $solarized-light-nc);
+ @include hljs-override('title\\.function', $solarized-light-nf);
+ @include hljs-override('variable\\.constant', $solarized-light-no);
+ @include hljs-override('variable\\.language', $solarized-light-nb);
+ @include hljs-override('params', $solarized-light-nb);
+
// Line numbers
.file-line-num {
@include line-link($black, 'link');
diff --git a/app/assets/stylesheets/highlight/themes/white.scss b/app/assets/stylesheets/highlight/themes/white.scss
index b0f6595feff..f6cce25671f 100644
--- a/app/assets/stylesheets/highlight/themes/white.scss
+++ b/app/assets/stylesheets/highlight/themes/white.scss
@@ -2,9 +2,18 @@
@import '../white_base';
@include conflict-colors('white');
+
+ // Highlight.js theme overrides (https://gitlab.com/gitlab-org/gitlab/-/issues/365167)
+ // We should be able to remove the overrides once the upstream issue is fixed (https://github.com/sourcegraph/sourcegraph/issues/23251)
@include hljs-override('variable', $white-nv);
@include hljs-override('symbol', $white-ss);
- @include hljs-override('title.class_.inherited__', $white-no);
+ @include hljs-override('title\\.class_\\.inherited__', $white-no);
+ @include hljs-override('title\\.class\\.inherited', $white-no);
+ @include hljs-override('title\\.class', $white-nc);
+ @include hljs-override('variable\\.constant', $white-no);
+ @include hljs-override('variable\\.language', $white-nb);
+ @include hljs-override('title\\.function', $white-nf);
+ @include hljs-override('params', $white-nb);
}
:root {
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index d45bc865da5..0cc1fb40e4a 100644
--- a/app/assets/stylesheets/page_bundles/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -18,38 +18,9 @@
.boards-list,
.board-swimlanes {
- height: calc(100vh - #{$issue-board-list-difference-xs});
overflow-x: scroll;
min-height: 200px;
border-left: 8px solid var(--gray-10, $white);
-
- @include media-breakpoint-only(sm) {
- height: calc(100vh - #{$issue-board-list-difference-sm});
- }
-
- @include media-breakpoint-up(md) {
- height: calc(100vh - #{$issue-board-list-difference-md});
- }
-
- @include media-breakpoint-up(lg) {
- height: calc(100vh - #{$issue-board-list-difference-lg});
- }
-
- .with-performance-bar & {
- height: calc(100vh - #{$issue-board-list-difference-xs} - #{$performance-bar-height});
-
- @include media-breakpoint-only(sm) {
- height: calc(100vh - #{$issue-board-list-difference-sm} - #{$performance-bar-height});
- }
-
- @include media-breakpoint-up(md) {
- height: calc(100vh - #{$issue-board-list-difference-md} - #{$performance-bar-height});
- }
-
- @include media-breakpoint-up(lg) {
- height: calc(100vh - #{$issue-board-list-difference-lg} - #{$performance-bar-height});
- }
- }
}
.board {
diff --git a/app/assets/stylesheets/pages/branches.scss b/app/assets/stylesheets/page_bundles/branches.scss
index 18158fab75f..2aa90529e22 100644
--- a/app/assets/stylesheets/pages/branches.scss
+++ b/app/assets/stylesheets/page_bundles/branches.scss
@@ -1,3 +1,5 @@
+@import 'mixins_and_variables_and_functions';
+
.branch-info {
flex: auto;
min-width: 0;
diff --git a/app/assets/stylesheets/page_bundles/clusters.scss b/app/assets/stylesheets/page_bundles/clusters.scss
index a877ae72e31..4f29ff4b1ad 100644
--- a/app/assets/stylesheets/page_bundles/clusters.scss
+++ b/app/assets/stylesheets/page_bundles/clusters.scss
@@ -20,3 +20,7 @@
min-height: 372px;
}
}
+
+.cluster-button-container:focus-within {
+ @include gl-focus;
+}
diff --git a/app/assets/stylesheets/page_bundles/dashboard.scss b/app/assets/stylesheets/page_bundles/dashboard.scss
new file mode 100644
index 00000000000..986a9cc530d
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/dashboard.scss
@@ -0,0 +1,5 @@
+@import 'mixins_and_variables_and_functions';
+
+.empty-state .svg-250 img {
+ max-width: $gl-spacing-scale-20;
+}
diff --git a/app/assets/stylesheets/page_bundles/design_management.scss b/app/assets/stylesheets/page_bundles/design_management.scss
new file mode 100644
index 00000000000..143682e1cd7
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/design_management.scss
@@ -0,0 +1,215 @@
+@import 'mixins_and_variables_and_functions';
+
+$design-pin-diameter: 28px;
+$design-pin-diameter-sm: 24px;
+$t-gray-a-16-design-pin: rgba($black, 0.16);
+
+.design-card-header {
+ 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;
+ }
+
+ .comment-indicator {
+ border-radius: 50%;
+ }
+
+ .comment-indicator,
+ .frame .design-note-pin {
+ &:active {
+ cursor: grabbing;
+ }
+ }
+}
+
+.design-list-item {
+ height: 280px;
+ text-decoration: none;
+
+ .icon-version-status {
+ position: absolute;
+ right: 10px;
+ top: 10px;
+ }
+
+ .card-body {
+ height: 230px;
+ }
+}
+
+// This is temporary class to be removed after feature flag removal: https://gitlab.com/gitlab-org/gitlab/-/issues/223197
+.design-list-item-new {
+ height: 210px;
+}
+
+.design-note-pin {
+ display: flex;
+ height: $design-pin-diameter;
+ width: $design-pin-diameter;
+ box-sizing: content-box;
+ background-color: var(--purple-500, $purple-500);
+ color: var(--white, $white);
+ font-weight: $gl-font-weight-bold;
+ border-radius: 50%;
+ z-index: 1;
+ padding: 0;
+ border: 0;
+
+ &.draft {
+ background-color: var(--orange-500, $orange-500);
+ }
+
+ &.resolved {
+ background-color: var(--gray-500, $gray-500);
+ }
+
+ &.on-image {
+ box-shadow: 0 2px 4px $t-gray-a-08, 0 0 1px $t-gray-a-24;
+ border: var(--white, $white) 2px solid;
+ will-change: transform, box-shadow, opacity;
+ // NOTE: verbose transition property required for Safari
+ transition: transform $general-hover-transition-duration linear, box-shadow $general-hover-transition-duration linear, opacity $general-hover-transition-duration linear;
+ transform-origin: 0 0;
+ transform: translate(-50%, -50%);
+
+ &:hover {
+ transform: scale(1.2) translate(-50%, -50%);
+ }
+
+ &:active {
+ box-shadow: 0 0 4px $t-gray-a-16-design-pin, 0 4px 12px $t-gray-a-16-design-pin;
+ }
+
+ &.inactive {
+ @include gl-opacity-5;
+
+ &:hover {
+ @include gl-opacity-10;
+ }
+ }
+ }
+
+ &.small {
+ position: absolute;
+ border: 1px solid var(--white, $white);
+ height: $design-pin-diameter-sm;
+ width: $design-pin-diameter-sm;
+ }
+
+ &.user-avatar {
+ top: 25px;
+ right: 8px;
+ }
+}
+
+.design-scaler-wrapper {
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+.image-notes {
+ overflow-y: scroll;
+ padding: $gl-padding;
+ padding-top: 50px;
+ background-color: var(--white, $white);
+ flex-shrink: 0;
+ min-width: 400px;
+ flex-basis: 28%;
+
+ .link-inherit-color {
+ &:hover,
+ &:active,
+ &:focus {
+ color: inherit;
+ text-decoration: none;
+ }
+ }
+
+ .toggle-comments {
+ line-height: 20px;
+ border-top: 1px solid var(--border-color, $border-color);
+
+ &.expanded {
+ border-bottom: 1px solid var(--border-color, $border-color);
+ }
+
+ .toggle-comments-button:focus {
+ text-decoration: none;
+ color: var(--blue-600, $blue-600);
+ }
+ }
+
+ .design-note-pin {
+ margin-left: $gl-padding;
+ }
+
+ .design-discussion {
+ margin: $gl-padding 0;
+
+ &::before {
+ content: '';
+ border-left: 1px solid var(--gray-100, $gray-100);
+ position: absolute;
+ left: 28px;
+ top: -17px;
+ height: 17px;
+ }
+
+ .design-note {
+ padding: $gl-padding;
+ list-style: none;
+ transition: background $gl-transition-duration-medium $general-hover-transition-curve;
+ border-top-left-radius: $border-radius-default; // same border radius used by .bordered-box
+ border-top-right-radius: $border-radius-default;
+
+ a {
+ color: inherit;
+ }
+
+ .note-text a {
+ color: var(--blue-600, $blue-600);
+ }
+ }
+
+ .reply-wrapper {
+ padding: $gl-padding;
+ }
+ }
+
+ .reply-wrapper {
+ border-top: 1px solid var(--border-color, $border-color);
+ }
+
+ .new-discussion-disclaimer {
+ line-height: 20px;
+ }
+}
+
+@media (max-width: map-get($grid-breakpoints, lg)) {
+ .design-detail {
+ overflow-y: scroll;
+ }
+
+ .image-notes {
+ overflow-y: auto;
+ min-width: 100%;
+ flex-grow: 1;
+ flex-basis: auto;
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 3951f72112f..ec75c53d026 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -568,6 +568,11 @@ $ide-commit-header-height: 48px;
text-decoration: line-through;
}
}
+
+ .gl-form-radio,
+ .gl-form-checkbox {
+ color: var(--ide-text-color, $gl-text-color);
+ }
}
.ide-sidebar-link {
diff --git a/app/assets/stylesheets/page_bundles/issues_show.scss b/app/assets/stylesheets/page_bundles/issues_show.scss
index bbdcf1ea0c6..26d694f7421 100644
--- a/app/assets/stylesheets/page_bundles/issues_show.scss
+++ b/app/assets/stylesheets/page_bundles/issues_show.scss
@@ -1,9 +1,5 @@
@import 'mixins_and_variables_and_functions';
-$design-pin-diameter: 28px;
-$design-pin-diameter-sm: 24px;
-$t-gray-a-16-design-pin: rgba($black, 0.16);
-
.description {
li {
position: relative;
@@ -27,216 +23,6 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
}
}
-.design-card-header {
- 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;
- }
-
- .comment-indicator {
- border-radius: 50%;
- }
-
- .comment-indicator,
- .frame .design-note-pin {
- &:active {
- cursor: grabbing;
- }
- }
-}
-
-.design-list-item {
- height: 280px;
- text-decoration: none;
-
- .icon-version-status {
- position: absolute;
- right: 10px;
- top: 10px;
- }
-
- .card-body {
- height: 230px;
- }
-}
-
-// This is temporary class to be removed after feature flag removal: https://gitlab.com/gitlab-org/gitlab/-/issues/223197
-.design-list-item-new {
- height: 210px;
-}
-
-.design-note-pin {
- display: flex;
- height: $design-pin-diameter;
- width: $design-pin-diameter;
- box-sizing: content-box;
- background-color: var(--purple-500, $purple-500);
- color: var(--white, $white);
- font-weight: $gl-font-weight-bold;
- border-radius: 50%;
- z-index: 1;
- padding: 0;
- border: 0;
-
- &.draft {
- background-color: var(--orange-500, $orange-500);
- }
-
- &.resolved {
- background-color: var(--gray-500, $gray-500);
- }
-
- &.on-image {
- box-shadow: 0 2px 4px $t-gray-a-08, 0 0 1px $t-gray-a-24;
- border: var(--white, $white) 2px solid;
- will-change: transform, box-shadow, opacity;
- // NOTE: verbose transition property required for Safari
- transition: transform $general-hover-transition-duration linear, box-shadow $general-hover-transition-duration linear, opacity $general-hover-transition-duration linear;
- transform-origin: 0 0;
- transform: translate(-50%, -50%);
-
- &:hover {
- transform: scale(1.2) translate(-50%, -50%);
- }
-
- &:active {
- box-shadow: 0 0 4px $t-gray-a-16-design-pin, 0 4px 12px $t-gray-a-16-design-pin;
- }
-
- &.inactive {
- @include gl-opacity-5;
-
- &:hover {
- @include gl-opacity-10;
- }
- }
- }
-
- &.small {
- position: absolute;
- border: 1px solid var(--white, $white);
- height: $design-pin-diameter-sm;
- width: $design-pin-diameter-sm;
- }
-
- &.user-avatar {
- top: 25px;
- right: 8px;
- }
-}
-
-.design-scaler-wrapper {
- bottom: 0;
- left: 50%;
- transform: translateX(-50%);
-}
-
-.image-notes {
- overflow-y: scroll;
- padding: $gl-padding;
- padding-top: 50px;
- background-color: var(--white, $white);
- flex-shrink: 0;
- min-width: 400px;
- flex-basis: 28%;
-
- .link-inherit-color {
- &:hover,
- &:active,
- &:focus {
- color: inherit;
- text-decoration: none;
- }
- }
-
- .toggle-comments {
- line-height: 20px;
- border-top: 1px solid var(--border-color, $border-color);
-
- &.expanded {
- border-bottom: 1px solid var(--border-color, $border-color);
- }
-
- .toggle-comments-button:focus {
- text-decoration: none;
- color: var(--blue-600, $blue-600);
- }
- }
-
- .design-note-pin {
- margin-left: $gl-padding;
- }
-
- .design-discussion {
- margin: $gl-padding 0;
-
- &::before {
- content: '';
- border-left: 1px solid var(--gray-100, $gray-100);
- position: absolute;
- left: 28px;
- top: -17px;
- height: 17px;
- }
-
- .design-note {
- padding: $gl-padding;
- list-style: none;
- transition: background $gl-transition-duration-medium $general-hover-transition-curve;
- border-top-left-radius: $border-radius-default; // same border radius used by .bordered-box
- border-top-right-radius: $border-radius-default;
-
- a {
- color: inherit;
- }
-
- .note-text a {
- color: var(--blue-600, $blue-600);
- }
- }
-
- .reply-wrapper {
- padding: $gl-padding;
- }
- }
-
- .reply-wrapper {
- border-top: 1px solid var(--border-color, $border-color);
- }
-
- .new-discussion-disclaimer {
- line-height: 20px;
- }
-}
-
-@media (max-width: map-get($grid-breakpoints, lg)) {
- .design-detail {
- overflow-y: scroll;
- }
-
- .image-notes {
- overflow-y: auto;
- min-width: 100%;
- flex-grow: 1;
- flex-basis: auto;
- }
-}
-
.is-ghost {
opacity: 0.3;
pointer-events: none;
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index b2fbce7cb4b..771428b49e0 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -269,7 +269,7 @@ $tabs-holder-z-index: 250;
position: -webkit-sticky;
position: sticky;
- top: var(--top-pos);
+ 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));
.drag-handle {
@@ -391,6 +391,10 @@ $tabs-holder-z-index: 250;
text-overflow: ellipsis;
min-width: 100px;
+ display: grid;
+ grid-template-columns: max-content minmax(0, max-content) max-content;
+ grid-gap: $gl-spacing-scale-2;
+
@include media-breakpoint-up(xs) {
min-width: 0;
max-width: 100%;
@@ -404,6 +408,7 @@ $tabs-holder-z-index: 250;
.deploy-heading,
.merge-train-position-indicator {
+ padding: $gl-padding-8;
@include media-breakpoint-up(md) {
padding: $gl-padding-8 $gl-padding;
}
@@ -476,6 +481,19 @@ $tabs-holder-z-index: 250;
border-radius: $border-radius-default;
background: var(--white, $white);
+ > .mr-widget-section {
+ > :first-child {
+ border-top-left-radius: $border-radius-default - 1px;
+ border-top-right-radius: $border-radius-default - 1px;
+ }
+
+ > :last-child,
+ .deploy-heading:last-child {
+ border-bottom-left-radius: $border-radius-default - 1px;
+ border-bottom-right-radius: $border-radius-default - 1px;
+ }
+ }
+
> .mr-widget-border-top:first-of-type {
border-top: 0;
}
@@ -637,7 +655,6 @@ $tabs-holder-z-index: 250;
word-break: break-all;
}
- .deploy-link,
.label-branch {
&.label-truncate {
// NOTE: This selector targets its children because some of the HTML comes from
@@ -808,6 +825,13 @@ $tabs-holder-z-index: 250;
.mr-widget-border-top {
border-top: 1px solid var(--border-color, $border-color);
+
+ &:last-child {
+ .report-block-container {
+ border-bottom-left-radius: $border-radius-default - 1px;
+ border-bottom-right-radius: $border-radius-default - 1px;
+ }
+ }
}
.mr-widget-extension {
@@ -875,9 +899,12 @@ $tabs-holder-z-index: 250;
}
.mr-widget-workflow {
- margin-top: $gl-padding;
position: relative;
+ &:not(:first-child) {
+ margin-top: $gl-padding;
+ }
+
&:not(:last-child)::before {
content: '';
border-left: 2px solid var(--gray-10, $gray-10);
@@ -1078,6 +1105,31 @@ $tabs-holder-z-index: 250;
border-top: 0;
}
+.mr-widget-status-icon-level-1::before {
+ @include gl-content-empty;
+ @include gl-absolute;
+ @include gl-left-0;
+ @include gl-top-0;
+ @include gl-bottom-0;
+ @include gl-right-0;
+ @include gl-opacity-3;
+ @include gl-rounded-full;
+ @include gl-border-solid;
+ @include gl-border-4;
+}
+
+.mr-widget-status-icon-level-1::after {
+ @include gl-content-empty;
+ @include gl-absolute;
+ @include gl-rounded-full;
+ @include gl-border-solid;
+ @include gl-border-4;
+ @include gl-left-2;
+ @include gl-right-2;
+ @include gl-top-2;
+ @include gl-bottom-2;
+}
+
.memory-graph-container {
background: var(--white, $white);
border: 1px solid var(--gray-100, $gray-100);
@@ -1154,3 +1206,19 @@ $tabs-holder-z-index: 250;
margin-bottom: 0;
}
}
+
+.commits ol:not(:last-of-type) {
+ margin-bottom: 0;
+}
+
+.mr-section-container {
+ .state-container-action-buttons {
+ @include media-breakpoint-up(md) {
+ flex-direction: row-reverse;
+
+ .btn {
+ margin-left: auto;
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/notifications.scss b/app/assets/stylesheets/page_bundles/notifications.scss
index 2fd2757cf08..88437954f4c 100644
--- a/app/assets/stylesheets/pages/notifications.scss
+++ b/app/assets/stylesheets/page_bundles/notifications.scss
@@ -1,3 +1,5 @@
+@import 'mixins_and_variables_and_functions';
+
.notification-list-item {
@include media-breakpoint-down(sm) {
.notification-dropdown {
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 6070311dcb6..1b6e7954366 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -76,10 +76,6 @@
.btn-edit {
margin-left: auto;
}
-
- .emoji-block {
- padding: $gl-padding-4 0;
- }
}
.issuable-show-labels {
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index c88834c088f..c2ac4f32480 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -75,11 +75,6 @@ ul.related-merge-requests > li gl-emoji {
.merge-request,
.issue {
- &.today {
- background: $issues-today-bg;
- border-color: $issues-today-border;
- }
-
&.closed,
&.merged {
background: $gray-light;
@@ -123,9 +118,6 @@ ul.related-merge-requests > li gl-emoji {
}
.new-branch-col {
- @include gl-pb-3;
- @include gl-my-2;
-
.discussion-filter-container {
&:not(:last-child) {
margin-right: $gl-spacing-scale-3;
@@ -225,7 +217,7 @@ ul.related-merge-requests > li gl-emoji {
.new-branch-col {
@include gl-pb-0;
- align-self: center;
+ align-self: flex-end;
}
}
diff --git a/app/assets/stylesheets/pages/ml_experiment_tracking.scss b/app/assets/stylesheets/pages/ml_experiment_tracking.scss
new file mode 100644
index 00000000000..2dff51cff92
--- /dev/null
+++ b/app/assets/stylesheets/pages/ml_experiment_tracking.scss
@@ -0,0 +1,16 @@
+@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;
+ }
+}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 438b7b1afa6..fa3c87490f1 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -6,7 +6,7 @@ $system-note-svg-size: 1rem;
content: '';
border-left: 2px solid var(--gray-10, $gray-10);
position: absolute;
- top: 0;
+ top: $gl-padding-6;
bottom: 0;
left: calc(#{$left} - 1px);
height: calc(100% + 1.5rem);
@@ -421,7 +421,7 @@ $system-note-svg-size: 1rem;
height: $system-note-icon-size;
border: 1px solid $gray-10;
border-radius: $system-note-icon-size;
- margin: -8px 0 0;
+ margin: -6px 0 0;
svg {
width: $system-note-svg-size;
@@ -968,7 +968,7 @@ $system-note-svg-size: 1rem;
height: 12px;
}
- &:hover,
+ &:hover:not([disabled]),
&.inverted {
&::before {
background-color: $white;
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index be8707dcd50..bf20204cfd9 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -277,139 +277,128 @@
.description p {
margin-bottom: 0;
color: $gl-text-color-secondary;
+ @include str-truncated(100%);
}
}
.projects-list {
@include basic-list;
- display: flex;
- flex-direction: column;
+ @include gl-display-table;
.project-row {
- @include basic-list-stats;
- display: flex;
- align-items: center;
- padding: $gl-padding-12 0;
+ @include gl-display-table-row;
}
- h2 {
- font-size: $gl-font-size;
- font-weight: $gl-font-weight-bold;
- margin-bottom: 0;
-
- @include media-breakpoint-up(sm) {
- .namespace-name {
- font-weight: $gl-font-weight-normal;
- }
- }
+ .project-cell {
+ @include gl-display-table-cell;
+ @include gl-border-b;
+ @include gl-vertical-align-top;
+ @include gl-py-4;
}
- .avatar-container {
- flex: 0 0 auto;
- align-self: flex-start;
+ .project-row:last-of-type {
+ .project-cell {
+ @include gl-border-none;
+ }
}
- .project-details {
- min-width: 0;
- line-height: $gl-line-height;
- .flex-wrapper {
- min-width: 0;
- margin-top: -$gl-padding-8; // negative margin required for flex-wrap
- flex: 1 1 100%;
+ &.admin-projects,
+ &.group-settings-projects {
+ .project-row {
+ @include basic-list-stats;
- .project-title {
- line-height: 20px;
+ .description > p {
+ @include gl-mb-0;
}
}
+ .controls {
+ @include gl-line-height-42;
+ }
+ }
+
+ .project-details {
+ max-width: 625px;
+
p,
.commit-row-message {
+ @include gl-mb-0;
@include str-truncated(100%);
- margin-bottom: 0;
- }
-
- .user-access-role {
- margin: 0;
}
.description {
line-height: 1.5;
- color: $gl-text-color-secondary;
- }
-
- @include media-breakpoint-down(md) {
- .user-access-role {
- line-height: $gl-line-height-14;
- }
+ max-height: $gl-spacing-scale-8;
}
}
.ci-status-link {
- display: inline-block;
- line-height: 17px;
- vertical-align: middle;
-
- &:hover {
- text-decoration: none;
- }
+ @include gl-text-decoration-none;
}
- .controls {
- @include media-breakpoint-down(xs) {
- margin-top: $gl-padding-8;
+ &:not(.compact) {
+ .controls {
+ @include media-breakpoint-up(lg) {
+ @include gl-justify-content-start;
+ @include gl-pr-9;
+
+ &:not(.with-pipeline-status) {
+ .icon-wrapper:first-of-type {
+ @include media-breakpoint-up(lg) {
+ @include gl-ml-7;
+ }
+ }
+ }
+ }
}
- @include media-breakpoint-up(sm) {
- margin-top: 0;
+ .project-details {
+ p,
+ .commit-row-message {
+ @include gl-white-space-normal;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ /* stylelint-disable-next-line value-no-vendor-prefix */
+ display: -webkit-box;
+ }
}
+ }
- @include media-breakpoint-up(lg) {
- flex: 1 1 40%;
+ .controls {
+ @include media-breakpoint-up(sm) {
+ @include gl-justify-content-end;
}
.icon-wrapper {
- color: inherit;
- margin-right: $gl-padding;
-
@include media-breakpoint-down(md) {
- margin-right: 0;
- margin-left: $gl-padding-8;
+ @include gl-mr-0;
+ @include gl-ml-3;
}
@include media-breakpoint-down(xs) {
&:first-child {
- margin-left: 0;
- }
- }
- }
-
- &:not(.with-pipeline-status) {
- .icon-wrapper:first-of-type {
- @include media-breakpoint-up(lg) {
- margin-left: $gl-padding-32;
+ @include gl-ml-0;
}
}
}
.ci-status-link {
- display: inline-flex;
+ @include gl-display-inline-flex;
}
}
.icon-container {
- @include media-breakpoint-down(xs) {
- margin-right: $gl-padding-8;
+ @include media-breakpoint-up(lg) {
+ margin-right: $gl-spacing-scale-7;
}
}
&.compact {
- .project-row {
- padding: $gl-padding 0;
- }
-
- h2 {
- font-size: $gl-font-size;
+ .description {
+ @include gl-w-full;
+ @include gl-display-table;
+ @include gl-table-layout-fixed;
}
.avatar-container {
@@ -422,27 +411,15 @@
}
}
- .controls {
- @include media-breakpoint-up(sm) {
- margin-top: 0;
- }
- }
-
.updated-note {
@include media-breakpoint-up(sm) {
- margin-top: $gl-padding-8;
+ @include gl-mt-2;
}
}
.icon-wrapper {
- margin-left: $gl-padding-8;
- margin-right: 0;
-
- @include media-breakpoint-down(xs) {
- &:first-child {
- margin-left: 0;
- }
- }
+ @include gl-ml-3;
+ @include gl-mr-0;
}
.user-access-role {
@@ -451,10 +428,6 @@
}
@include media-breakpoint-down(md) {
- h2 {
- font-size: $gl-font-size;
- }
-
.avatar-container {
@include avatar-size(40px, 10px);
min-height: 40px;
@@ -468,24 +441,18 @@
@include media-breakpoint-down(md) {
.updated-note {
- margin-top: $gl-padding-8;
- text-align: right;
+ @include gl-mt-3;
+ @include gl-text-right;
}
}
.forks,
.pipeline-status,
.updated-note {
- display: flex;
+ @include gl-display-flex;
}
@include media-breakpoint-down(md) {
- &:not(.explore) {
- .forks {
- display: none;
- }
- }
-
&.explore {
.pipeline-status,
.updated-note {
@@ -496,8 +463,8 @@
@include media-breakpoint-down(xs) {
.updated-note {
- margin-top: 0;
- text-align: left;
+ @include gl-mt-0;
+ @include gl-text-left;
}
}
}
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index a8027d2a5f5..1bca04e5eb1 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -52,13 +52,6 @@ input[type='checkbox']:hover {
.global-search-container {
flex-grow: 1;
}
-
- @include media-breakpoint-down(lg) {
- .title-container {
- flex: 0;
- overflow: hidden;
- }
- }
}
}
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index 32c3ce1ba8c..11131cc1a4b 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -6,15 +6,15 @@
color-scheme: dark;
}
body.gl-dark {
- --gray-10: #1f1f1f;
- --gray-50: #303030;
- --gray-100: #404040;
- --gray-200: #525252;
- --gray-700: #dbdbdb;
- --gray-900: #fafafa;
+ --gray-10: #1f1e24;
+ --gray-50: #333238;
+ --gray-100: #434248;
+ --gray-200: #535158;
+ --gray-700: #bfbfc3;
+ --gray-900: #ececef;
--green-100: #0d532a;
--green-700: #91d4a8;
- --gl-text-color: #fafafa;
+ --gl-text-color: #ececef;
--border-color: #4f4f4f;
--black: #fff;
}
@@ -42,9 +42,9 @@ body {
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
- color: #fafafa;
+ color: #ececef;
text-align: left;
- background-color: #1f1f1f;
+ background-color: #1f1e24;
}
ul {
margin-top: 0;
@@ -118,7 +118,7 @@ kbd {
padding: 0.2rem 0.4rem;
font-size: 90%;
color: #333;
- background-color: #fafafa;
+ background-color: #ececef;
border-radius: 0.2rem;
}
kbd kbd {
@@ -141,24 +141,24 @@ kbd kbd {
font-size: 0.875rem;
font-weight: 400;
line-height: 1.5;
- color: #fafafa;
+ color: #ececef;
background-color: #333;
background-clip: padding-box;
- border: 1px solid #868686;
+ border: 1px solid #737278;
border-radius: 0.25rem;
}
@media (prefers-reduced-motion: reduce) {
}
.form-control:-moz-focusring {
color: transparent;
- text-shadow: 0 0 0 #fafafa;
+ text-shadow: 0 0 0 #ececef;
}
.form-control::placeholder {
- color: #bfbfbf;
+ color: #a4a3a8;
opacity: 1;
}
.form-control:disabled {
- background-color: #303030;
+ background-color: #333238;
opacity: 1;
}
.form-inline {
@@ -176,7 +176,7 @@ kbd kbd {
.btn {
display: inline-block;
font-weight: 400;
- color: #fafafa;
+ color: #ececef;
text-align: center;
vertical-align: middle;
user-select: none;
@@ -212,7 +212,7 @@ kbd kbd {
padding: 0.5rem 0;
margin: 0.125rem 0 0;
font-size: 1rem;
- color: #fafafa;
+ color: #ececef;
text-align: left;
list-style: none;
background-color: #333;
@@ -319,15 +319,15 @@ kbd kbd {
border-radius: 10rem;
}
.badge-success {
- color: #fff;
+ color: #fbfafd;
background-color: #2da160;
}
.badge-info {
- color: #fff;
+ color: #fbfafd;
background-color: #428fdc;
}
.badge-warning {
- color: #fff;
+ color: #fbfafd;
background-color: #c17d10;
}
.rounded-circle {
@@ -371,7 +371,7 @@ kbd kbd {
.gl-avatar {
border-width: 1px;
border-style: solid;
- border-color: rgba(0, 0, 0, 0.08);
+ border-color: rgba(251, 250, 253, 0.08);
overflow: hidden;
flex-shrink: 0;
}
@@ -455,8 +455,8 @@ a.gl-badge.badge-warning:active {
padding-left: 0.75rem;
padding-right: 0.75rem;
height: auto;
- color: #fafafa;
- box-shadow: inset 0 0 0 1px #868686;
+ color: #ececef;
+ box-shadow: inset 0 0 0 1px #737278;
border-style: none;
appearance: none;
-moz-appearance: none;
@@ -465,17 +465,17 @@ a.gl-badge.badge-warning:active {
.gl-form-input:not(.form-control-plaintext):not([type="color"]):read-only,
.gl-form-input.form-control:disabled,
.gl-form-input.form-control:not(.form-control-plaintext):not([type="color"]):read-only {
- background-color: #1f1f1f;
- box-shadow: inset 0 0 0 1px #404040;
+ background-color: #1f1e24;
+ box-shadow: inset 0 0 0 1px #434248;
}
.gl-form-input:disabled,
.gl-form-input.form-control:disabled {
cursor: not-allowed;
- color: #999;
+ color: #89888d;
}
.gl-form-input::placeholder,
.gl-form-input.form-control::placeholder {
- color: #868686;
+ color: #737278;
}
.gl-icon {
fill: currentColor;
@@ -518,9 +518,9 @@ a.gl-badge.badge-warning:active {
padding-right: 0.75rem;
background-color: transparent;
line-height: 1rem;
- color: #fafafa;
+ color: #ececef;
fill: currentColor;
- box-shadow: inset 0 0 0 1px #525252;
+ box-shadow: inset 0 0 0 1px #535158;
justify-content: center;
align-items: center;
font-size: 0.875rem;
@@ -531,20 +531,20 @@ a.gl-badge.badge-warning:active {
}
.gl-button.gl-button.btn-default:active,
.gl-button.gl-button.btn-default.active {
- box-shadow: inset 0 0 0 1px #bfbfbf, 0 0 0 1px #333, 0 0 0 3px #1f75cb;
+ box-shadow: inset 0 0 0 1px #a4a3a8, 0 0 0 1px #333, 0 0 0 3px #1f75cb;
outline: none;
- background-color: #404040;
+ background-color: #434248;
}
.gl-button.gl-button.btn-default:active .gl-icon,
.gl-button.gl-button.btn-default.active .gl-icon {
- color: #fafafa;
+ color: #ececef;
}
.gl-button.gl-button.btn-default .gl-icon {
- color: #999;
+ color: #89888d;
}
.gl-search-box-by-type-search-icon {
margin: 0.5rem;
- color: #999;
+ color: #89888d;
width: 1rem;
position: absolute;
}
@@ -594,35 +594,40 @@ svg {
height: 0;
margin: 4px 0;
overflow: hidden;
- border-top: 1px solid #404040;
+ border-top: 1px solid #434248;
}
.toggle-sidebar-button .collapse-text,
.toggle-sidebar-button .icon-chevron-double-lg-left {
- color: #999;
+ color: #89888d;
}
html {
overflow-y: scroll;
}
+@media (min-width: 576px) {
+ .logged-out-marketing-header {
+ --header-height: 72px;
+ }
+}
.btn {
border-radius: 4px;
font-size: 0.875rem;
font-weight: 400;
padding: 6px 10px;
background-color: #333;
- border-color: #404040;
- color: #fafafa;
- color: #fafafa;
+ border-color: #434248;
+ color: #ececef;
+ color: #ececef;
white-space: nowrap;
}
.btn:active {
- background-color: #303030;
+ background-color: #333238;
box-shadow: none;
}
.btn:active,
.btn.active {
background-color: #444;
border-color: #4f4f4f;
- color: #fafafa;
+ color: #ececef;
}
.btn svg {
height: 15px;
@@ -634,7 +639,7 @@ html {
.badge.badge-pill:not(.gl-badge) {
font-weight: 400;
background-color: rgba(255, 255, 255, 0.07);
- color: #dbdbdb;
+ color: #bfbfc3;
vertical-align: baseline;
}
.gl-font-sm {
@@ -653,10 +658,10 @@ html {
.dropdown-menu-toggle {
padding: 6px 8px 6px 10px;
background-color: #333;
- color: #fafafa;
+ color: #ececef;
font-size: 14px;
text-align: left;
- border: 1px solid #404040;
+ border: 1px solid #434248;
border-radius: 0.25rem;
white-space: nowrap;
}
@@ -685,7 +690,7 @@ html {
font-weight: 400;
padding: 8px 0;
background-color: #333;
- border: 1px solid #404040;
+ border: 1px solid #434248;
border-radius: 0.25rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
@@ -708,7 +713,7 @@ html {
font-weight: 400;
position: relative;
padding: 8px 12px;
- color: #fafafa;
+ color: #ececef;
line-height: 16px;
white-space: normal;
overflow: hidden;
@@ -718,7 +723,7 @@ html {
.dropdown-menu li > a:active,
.dropdown-menu li button:active {
background-color: #4f4f4f;
- color: #fafafa;
+ color: #ececef;
outline: 0;
text-decoration: none;
}
@@ -732,7 +737,7 @@ html {
height: 1px;
margin: 0.25rem 0;
padding: 0;
- background-color: #404040;
+ background-color: #434248;
}
.dropdown-menu .badge.badge-pill + span:not(.badge):not(.badge-pill) {
margin-right: 40px;
@@ -759,7 +764,7 @@ html {
}
input {
border-radius: 0.25rem;
- color: #fafafa;
+ color: #ececef;
background-color: #333;
}
.form-control {
@@ -767,23 +772,23 @@ input {
padding: 6px 10px;
}
.form-control::placeholder {
- color: #868686;
+ color: #737278;
}
kbd {
display: inline-block;
padding: 3px 5px;
font-size: 0.6875rem;
line-height: 10px;
- color: var(--gray-700, #dbdbdb);
+ color: var(--gray-700, #bfbfc3);
vertical-align: middle;
- background-color: var(--gray-10, #1f1f1f);
+ background-color: var(--gray-10, #1f1e24);
border-width: 1px;
border-style: solid;
- border-color: var(--gray-100, #404040) var(--gray-100, #404040)
- var(--gray-200, #525252);
+ border-color: var(--gray-100, #434248) var(--gray-100, #434248)
+ var(--gray-200, #535158);
border-image: none;
border-radius: 3px;
- box-shadow: 0 -1px 0 var(--gray-200, #525252) inset;
+ box-shadow: 0 -1px 0 var(--gray-200, #535158) inset;
}
.navbar-gitlab {
padding: 0 16px;
@@ -1037,7 +1042,7 @@ kbd {
width: 100%;
align-items: center;
padding: 10px 16px 10px 10px;
- color: #fafafa;
+ color: #ececef;
background-color: transparent;
border: 0;
text-align: left;
@@ -1049,7 +1054,7 @@ kbd {
.context-header .sidebar-context-title {
overflow: hidden;
text-overflow: ellipsis;
- color: #fafafa;
+ color: #ececef;
}
@media (min-width: 768px) {
.page-with-contextual-sidebar {
@@ -1073,7 +1078,7 @@ kbd {
z-index: 600;
width: 256px;
top: var(--header-height, 48px);
- background-color: #f5f5f5;
+ background-color: #1f1e24;
border-right: 1px solid #e9e9e9;
transform: translate3d(0, 0, 0);
}
@@ -1110,7 +1115,7 @@ kbd {
}
.nav-sidebar a {
text-decoration: none;
- color: #fafafa;
+ color: #ececef;
}
.nav-sidebar li {
white-space: nowrap;
@@ -1395,7 +1400,7 @@ kbd {
display: block;
}
.sidebar-top-level-items li > a.gl-link {
- color: #fafafa;
+ color: #ececef;
}
.sidebar-top-level-items li > a.gl-link:active {
text-decoration: none;
@@ -1412,12 +1417,12 @@ kbd {
.close-nav-button {
height: 48px;
padding: 0 16px;
- background-color: #303030;
+ background-color: #333238;
border: 0;
- color: #999;
+ color: #89888d;
display: flex;
align-items: center;
- background-color: #f5f5f5;
+ background-color: #1f1e24;
position: fixed;
bottom: 0;
width: 255px;
@@ -1488,14 +1493,14 @@ kbd {
}
}
input::-moz-placeholder {
- color: #868686;
+ color: #737278;
opacity: 1;
}
input::-ms-input-placeholder {
- color: #868686;
+ color: #737278;
}
input:-ms-input-placeholder {
- color: #868686;
+ color: #737278;
}
svg {
fill: currentColor;
@@ -1624,7 +1629,7 @@ svg.s16 {
padding: 0;
background: #222;
overflow: hidden;
- box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
+ box-shadow: inset 0 0 0 1px rgba(251, 250, 253, 0.1);
}
.avatar.avatar-tile {
border-radius: 0;
@@ -1633,8 +1638,8 @@ svg.s16 {
.identicon {
text-align: center;
vertical-align: top;
- color: #fafafa;
- background-color: #303030;
+ color: #ececef;
+ background-color: #333238;
}
.identicon.s16 {
font-size: 10px;
@@ -1663,7 +1668,7 @@ svg.s16 {
background-color: #5c2900;
}
.identicon.bg7 {
- background-color: #303030;
+ background-color: #333238;
}
.avatar-container {
overflow: hidden;
@@ -1702,18 +1707,18 @@ svg.s16 {
color-scheme: dark;
}
body.gl-dark {
- --gray-10: #1f1f1f;
- --gray-50: #303030;
- --gray-100: #404040;
- --gray-200: #525252;
- --gray-300: #5e5e5e;
- --gray-400: #868686;
- --gray-500: #999;
- --gray-600: #bfbfbf;
- --gray-700: #dbdbdb;
- --gray-800: #f0f0f0;
- --gray-900: #fafafa;
- --gray-950: #fff;
+ --gray-10: #1f1e24;
+ --gray-50: #333238;
+ --gray-100: #434248;
+ --gray-200: #535158;
+ --gray-300: #626168;
+ --gray-400: #737278;
+ --gray-500: #89888d;
+ --gray-600: #a4a3a8;
+ --gray-700: #bfbfc3;
+ --gray-800: #dcdcde;
+ --gray-900: #ececef;
+ --gray-950: #fbfafd;
--green-50: #0a4020;
--green-100: #0d532a;
--green-200: #24663b;
@@ -1785,58 +1790,59 @@ body.gl-dark {
--dark-icon-color-purple-3: #9a79f7;
--dark-icon-color-orange-1: #665349;
--dark-icon-color-orange-2: #b37a5d;
- --gl-text-color: #fafafa;
+ --gl-text-color: #ececef;
--border-color: #4f4f4f;
--white: #333;
--black: #fff;
+ --gray-light: #333238;
--svg-status-bg: #333;
}
.nav-sidebar,
.toggle-sidebar-button,
.close-nav-button {
- background-color: #262626;
- border-right: 1px solid #303030;
+ background-color: #29282d;
+ border-right: 1px solid #333238;
}
.gl-avatar:not(.gl-avatar-identicon),
.avatar-container,
.avatar {
- background: rgba(255, 255, 255, 0.04);
+ background: rgba(251, 250, 253, 0.04);
}
.gl-avatar {
border-style: none;
- box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
+ box-shadow: inset 0 0 0 1px rgba(251, 250, 253, 0.1);
}
body.gl-dark {
- --gl-theme-accent: #868686;
+ --gl-theme-accent: #737278;
}
body.gl-dark .navbar-gitlab {
- background-color: #fafafa;
+ background-color: #ececef;
}
body.gl-dark .navbar-gitlab .navbar-collapse {
- color: #fafafa;
+ color: #ececef;
}
body.gl-dark .navbar-gitlab .container-fluid .navbar-toggler {
- border-left: 1px solid #b3b3b3;
- color: #fafafa;
+ border-left: 1px solid #a3a2a6;
+ color: #ececef;
}
body.gl-dark .navbar-gitlab .navbar-sub-nav > li.active > a,
body.gl-dark .navbar-gitlab .navbar-sub-nav > li.active > button,
body.gl-dark .navbar-gitlab .navbar-nav > li.active > a,
body.gl-dark .navbar-gitlab .navbar-nav > li.active > button {
- color: #fafafa;
+ color: #ececef;
background-color: #333;
}
body.gl-dark .navbar-gitlab .navbar-sub-nav {
- color: #fafafa;
+ color: #ececef;
}
body.gl-dark .navbar-gitlab .nav > li {
- color: #fafafa;
+ color: #ececef;
}
body.gl-dark .navbar-gitlab .nav > li.header-search-new {
- color: #fafafa;
+ color: #ececef;
}
body.gl-dark .navbar-gitlab .nav > li > a .notification-dot {
- border: 2px solid #fafafa;
+ border: 2px solid #ececef;
}
body.gl-dark
.navbar-gitlab
@@ -1844,7 +1850,7 @@ body.gl-dark
> li
> a.header-help-dropdown-toggle
.notification-dot {
- background-color: #fafafa;
+ background-color: #ececef;
}
body.gl-dark
.navbar-gitlab
@@ -1852,10 +1858,10 @@ body.gl-dark
> li
> a.header-user-dropdown-toggle
.header-user-avatar {
- border-color: #fafafa;
+ border-color: #ececef;
}
body.gl-dark .navbar-gitlab .nav > li.active > a {
- color: #fafafa;
+ color: #ececef;
background-color: #333;
}
body.gl-dark .navbar-gitlab .nav > li.active > a .notification-dot {
@@ -1867,48 +1873,48 @@ body.gl-dark
> li.active
> a.header-help-dropdown-toggle
.notification-dot {
- background-color: #fafafa;
+ background-color: #ececef;
}
body.gl-dark .header-search {
- background-color: rgba(250, 250, 250, 0.2) !important;
+ background-color: rgba(236, 236, 239, 0.2) !important;
border-radius: 4px;
}
body.gl-dark .header-search svg.gl-search-box-by-type-search-icon {
- color: rgba(250, 250, 250, 0.8);
+ color: rgba(236, 236, 239, 0.8);
}
body.gl-dark .header-search input {
background-color: transparent;
- color: rgba(250, 250, 250, 0.8);
- box-shadow: inset 0 0 0 1px rgba(250, 250, 250, 0.4);
+ color: rgba(236, 236, 239, 0.8);
+ box-shadow: inset 0 0 0 1px rgba(236, 236, 239, 0.4);
}
body.gl-dark .header-search input::placeholder {
- color: rgba(250, 250, 250, 0.8);
+ color: rgba(236, 236, 239, 0.8);
}
body.gl-dark .header-search input:active::placeholder {
- color: #868686;
+ color: #737278;
}
body.gl-dark .header-search .keyboard-shortcut-helper {
- color: #fafafa;
- background-color: rgba(250, 250, 250, 0.2);
+ color: #ececef;
+ background-color: rgba(236, 236, 239, 0.2);
}
body.gl-dark .search form {
- background-color: rgba(250, 250, 250, 0.2);
+ background-color: rgba(236, 236, 239, 0.2);
}
body.gl-dark .search .search-input::placeholder {
- color: rgba(250, 250, 250, 0.8);
+ 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(250, 250, 250, 0.8);
+ fill: rgba(236, 236, 239, 0.8);
}
body.gl-dark .nav-sidebar li.active > a {
- color: #fafafa;
+ color: #ececef;
}
body.gl-dark .nav-sidebar .fly-out-top-item a,
body.gl-dark .nav-sidebar .fly-out-top-item.active a,
body.gl-dark .nav-sidebar .fly-out-top-item .fly-out-top-item-container {
- background-color: var(--gray-100, #303030);
- color: var(--gray-900, #fafafa);
+ background-color: var(--gray-100, #333238);
+ color: var(--gray-900, #ececef);
}
body.gl-dark .navbar-gitlab {
background-color: var(--gray-50);
@@ -1945,18 +1951,18 @@ body.gl-dark .navbar-gitlab .search form .search-input {
color-scheme: dark;
}
body.gl-dark {
- --gray-10: #1f1f1f;
- --gray-50: #303030;
- --gray-100: #404040;
- --gray-200: #525252;
- --gray-300: #5e5e5e;
- --gray-400: #868686;
- --gray-500: #999;
- --gray-600: #bfbfbf;
- --gray-700: #dbdbdb;
- --gray-800: #f0f0f0;
- --gray-900: #fafafa;
- --gray-950: #fff;
+ --gray-10: #1f1e24;
+ --gray-50: #333238;
+ --gray-100: #434248;
+ --gray-200: #535158;
+ --gray-300: #626168;
+ --gray-400: #737278;
+ --gray-500: #89888d;
+ --gray-600: #a4a3a8;
+ --gray-700: #bfbfc3;
+ --gray-800: #dcdcde;
+ --gray-900: #ececef;
+ --gray-950: #fbfafd;
--green-50: #0a4020;
--green-100: #0d532a;
--green-200: #24663b;
@@ -2028,10 +2034,11 @@ body.gl-dark {
--dark-icon-color-purple-3: #9a79f7;
--dark-icon-color-orange-1: #665349;
--dark-icon-color-orange-2: #b37a5d;
- --gl-text-color: #fafafa;
+ --gl-text-color: #ececef;
--border-color: #4f4f4f;
--white: #333;
--black: #fff;
+ --gray-light: #333238;
--svg-status-bg: #333;
}
.tab-width-8 {
@@ -2054,9 +2061,19 @@ body.gl-dark {
.gl-display-none {
display: none;
}
+@media (min-width: 576px) {
+ .gl-sm-display-none {
+ display: none;
+ }
+}
.gl-display-flex {
display: flex;
}
+@media (min-width: 992px) {
+ .gl-lg-display-flex {
+ display: flex;
+ }
+}
@media (min-width: 576px) {
.gl-sm-display-block {
display: block;
@@ -2067,9 +2084,6 @@ body.gl-dark {
display: block;
}
}
-.gl-display-inline-block\! {
- display: inline-block !important;
-}
.gl-align-items-center {
align-items: center;
}
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index 61a2ce8dd62..7fb373bb6f4 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -23,7 +23,7 @@ body {
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
- color: #303030;
+ color: #333238;
text-align: left;
background-color: #fff;
}
@@ -99,7 +99,7 @@ kbd {
padding: 0.2rem 0.4rem;
font-size: 90%;
color: #fff;
- background-color: #303030;
+ background-color: #333238;
border-radius: 0.2rem;
}
kbd kbd {
@@ -122,24 +122,24 @@ kbd kbd {
font-size: 0.875rem;
font-weight: 400;
line-height: 1.5;
- color: #303030;
+ color: #333238;
background-color: #fff;
background-clip: padding-box;
- border: 1px solid #868686;
+ border: 1px solid #89888d;
border-radius: 0.25rem;
}
@media (prefers-reduced-motion: reduce) {
}
.form-control:-moz-focusring {
color: transparent;
- text-shadow: 0 0 0 #303030;
+ text-shadow: 0 0 0 #333238;
}
.form-control::placeholder {
- color: #5e5e5e;
+ color: #626168;
opacity: 1;
}
.form-control:disabled {
- background-color: #fafafa;
+ background-color: #fbfafd;
opacity: 1;
}
.form-inline {
@@ -157,7 +157,7 @@ kbd kbd {
.btn {
display: inline-block;
font-weight: 400;
- color: #303030;
+ color: #333238;
text-align: center;
vertical-align: middle;
user-select: none;
@@ -193,7 +193,7 @@ kbd kbd {
padding: 0.5rem 0;
margin: 0.125rem 0 0;
font-size: 1rem;
- color: #303030;
+ color: #333238;
text-align: left;
list-style: none;
background-color: #fff;
@@ -352,7 +352,7 @@ kbd kbd {
.gl-avatar {
border-width: 1px;
border-style: solid;
- border-color: rgba(0, 0, 0, 0.08);
+ border-color: rgba(31, 30, 36, 0.08);
overflow: hidden;
flex-shrink: 0;
}
@@ -436,8 +436,8 @@ a.gl-badge.badge-warning:active {
padding-left: 0.75rem;
padding-right: 0.75rem;
height: auto;
- color: #303030;
- box-shadow: inset 0 0 0 1px #868686;
+ color: #333238;
+ box-shadow: inset 0 0 0 1px #89888d;
border-style: none;
appearance: none;
-moz-appearance: none;
@@ -446,17 +446,17 @@ a.gl-badge.badge-warning:active {
.gl-form-input:not(.form-control-plaintext):not([type="color"]):read-only,
.gl-form-input.form-control:disabled,
.gl-form-input.form-control:not(.form-control-plaintext):not([type="color"]):read-only {
- background-color: #f5f5f5;
- box-shadow: inset 0 0 0 1px #dbdbdb;
+ background-color: #fbfafd;
+ box-shadow: inset 0 0 0 1px #dcdcde;
}
.gl-form-input:disabled,
.gl-form-input.form-control:disabled {
cursor: not-allowed;
- color: #666;
+ color: #737278;
}
.gl-form-input::placeholder,
.gl-form-input.form-control::placeholder {
- color: #868686;
+ color: #89888d;
}
.gl-icon {
fill: currentColor;
@@ -499,9 +499,9 @@ a.gl-badge.badge-warning:active {
padding-right: 0.75rem;
background-color: transparent;
line-height: 1rem;
- color: #303030;
+ color: #333238;
fill: currentColor;
- box-shadow: inset 0 0 0 1px #bfbfbf;
+ box-shadow: inset 0 0 0 1px #bfbfc3;
justify-content: center;
align-items: center;
font-size: 0.875rem;
@@ -512,20 +512,20 @@ a.gl-badge.badge-warning:active {
}
.gl-button.gl-button.btn-default:active,
.gl-button.gl-button.btn-default.active {
- box-shadow: inset 0 0 0 1px #5e5e5e, 0 0 0 1px #fff, 0 0 0 3px #428fdc;
+ box-shadow: inset 0 0 0 1px #626168, 0 0 0 1px #fff, 0 0 0 3px #428fdc;
outline: none;
- background-color: #dbdbdb;
+ background-color: #dcdcde;
}
.gl-button.gl-button.btn-default:active .gl-icon,
.gl-button.gl-button.btn-default.active .gl-icon {
- color: #303030;
+ color: #333238;
}
.gl-button.gl-button.btn-default .gl-icon {
- color: #666;
+ color: #737278;
}
.gl-search-box-by-type-search-icon {
margin: 0.5rem;
- color: #666;
+ color: #737278;
width: 1rem;
position: absolute;
}
@@ -575,35 +575,40 @@ svg {
height: 0;
margin: 4px 0;
overflow: hidden;
- border-top: 1px solid #dbdbdb;
+ border-top: 1px solid #dcdcde;
}
.toggle-sidebar-button .collapse-text,
.toggle-sidebar-button .icon-chevron-double-lg-left {
- color: #666;
+ color: #737278;
}
html {
overflow-y: scroll;
}
+@media (min-width: 576px) {
+ .logged-out-marketing-header {
+ --header-height: 72px;
+ }
+}
.btn {
border-radius: 4px;
font-size: 0.875rem;
font-weight: 400;
padding: 6px 10px;
background-color: #fff;
- border-color: #dbdbdb;
- color: #303030;
- color: #303030;
+ border-color: #dcdcde;
+ color: #333238;
+ color: #333238;
white-space: nowrap;
}
.btn:active {
- background-color: #f0f0f0;
+ background-color: #ececef;
box-shadow: none;
}
.btn:active,
.btn.active {
background-color: #eaeaea;
border-color: #e3e3e3;
- color: #303030;
+ color: #333238;
}
.btn svg {
height: 15px;
@@ -615,7 +620,7 @@ html {
.badge.badge-pill:not(.gl-badge) {
font-weight: 400;
background-color: rgba(0, 0, 0, 0.07);
- color: #525252;
+ color: #535158;
vertical-align: baseline;
}
.gl-font-sm {
@@ -634,10 +639,10 @@ html {
.dropdown-menu-toggle {
padding: 6px 8px 6px 10px;
background-color: #fff;
- color: #303030;
+ color: #333238;
font-size: 14px;
text-align: left;
- border: 1px solid #dbdbdb;
+ border: 1px solid #dcdcde;
border-radius: 0.25rem;
white-space: nowrap;
}
@@ -666,7 +671,7 @@ html {
font-weight: 400;
padding: 8px 0;
background-color: #fff;
- border: 1px solid #dbdbdb;
+ border: 1px solid #dcdcde;
border-radius: 0.25rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
@@ -689,7 +694,7 @@ html {
font-weight: 400;
position: relative;
padding: 8px 12px;
- color: #303030;
+ color: #333238;
line-height: 16px;
white-space: normal;
overflow: hidden;
@@ -698,8 +703,8 @@ html {
}
.dropdown-menu li > a:active,
.dropdown-menu li button:active {
- background-color: #eee;
- color: #303030;
+ background-color: #ececef;
+ color: #333238;
outline: 0;
text-decoration: none;
}
@@ -713,7 +718,7 @@ html {
height: 1px;
margin: 0.25rem 0;
padding: 0;
- background-color: #dbdbdb;
+ background-color: #dcdcde;
}
.dropdown-menu .badge.badge-pill + span:not(.badge):not(.badge-pill) {
margin-right: 40px;
@@ -740,7 +745,7 @@ html {
}
input {
border-radius: 0.25rem;
- color: #303030;
+ color: #333238;
background-color: #fff;
}
.form-control {
@@ -748,23 +753,23 @@ input {
padding: 6px 10px;
}
.form-control::placeholder {
- color: #868686;
+ color: #89888d;
}
kbd {
display: inline-block;
padding: 3px 5px;
font-size: 0.6875rem;
line-height: 10px;
- color: var(--gray-700, #525252);
+ color: var(--gray-700, #535158);
vertical-align: middle;
- background-color: var(--gray-10, #f5f5f5);
+ background-color: var(--gray-10, #fbfafd);
border-width: 1px;
border-style: solid;
- border-color: var(--gray-100, #dbdbdb) var(--gray-100, #dbdbdb)
- var(--gray-200, #bfbfbf);
+ border-color: var(--gray-100, #dcdcde) var(--gray-100, #dcdcde)
+ var(--gray-200, #bfbfc3);
border-image: none;
border-radius: 3px;
- box-shadow: 0 -1px 0 var(--gray-200, #bfbfbf) inset;
+ box-shadow: 0 -1px 0 var(--gray-200, #bfbfc3) inset;
}
.navbar-gitlab {
padding: 0 16px;
@@ -986,7 +991,7 @@ kbd {
float: left;
margin-right: 5px;
border-radius: 50%;
- border: 1px solid #f5f5f5;
+ border: 1px solid #f2f2f4;
}
.notification-dot {
background-color: #d99530;
@@ -1018,7 +1023,7 @@ kbd {
width: 100%;
align-items: center;
padding: 10px 16px 10px 10px;
- color: #303030;
+ color: #333238;
background-color: transparent;
border: 0;
text-align: left;
@@ -1030,7 +1035,7 @@ kbd {
.context-header .sidebar-context-title {
overflow: hidden;
text-overflow: ellipsis;
- color: #303030;
+ color: #333238;
}
@media (min-width: 768px) {
.page-with-contextual-sidebar {
@@ -1054,7 +1059,7 @@ kbd {
z-index: 600;
width: 256px;
top: var(--header-height, 48px);
- background-color: #f5f5f5;
+ background-color: #fbfafd;
border-right: 1px solid #e9e9e9;
transform: translate3d(0, 0, 0);
}
@@ -1091,7 +1096,7 @@ kbd {
}
.nav-sidebar a {
text-decoration: none;
- color: #303030;
+ color: #333238;
}
.nav-sidebar li {
white-space: nowrap;
@@ -1376,7 +1381,7 @@ kbd {
display: block;
}
.sidebar-top-level-items li > a.gl-link {
- color: #303030;
+ color: #333238;
}
.sidebar-top-level-items li > a.gl-link:active {
text-decoration: none;
@@ -1393,12 +1398,12 @@ kbd {
.close-nav-button {
height: 48px;
padding: 0 16px;
- background-color: #fafafa;
+ background-color: #fbfafd;
border: 0;
- color: #666;
+ color: #737278;
display: flex;
align-items: center;
- background-color: #f5f5f5;
+ background-color: #fbfafd;
position: fixed;
bottom: 0;
width: 255px;
@@ -1469,14 +1474,14 @@ kbd {
}
}
input::-moz-placeholder {
- color: #868686;
+ color: #89888d;
opacity: 1;
}
input::-ms-input-placeholder {
- color: #868686;
+ color: #89888d;
}
input:-ms-input-placeholder {
- color: #868686;
+ color: #89888d;
}
svg {
fill: currentColor;
@@ -1603,9 +1608,9 @@ svg.s16 {
width: 40px;
height: 40px;
padding: 0;
- background: #fdfdfd;
+ background: #fefefe;
overflow: hidden;
- box-shadow: inset 0 0 0 1px rgba(31, 31, 31, 0.1);
+ box-shadow: inset 0 0 0 1px rgba(31, 30, 36, 0.1);
}
.avatar.avatar-tile {
border-radius: 0;
@@ -1614,8 +1619,8 @@ svg.s16 {
.identicon {
text-align: center;
vertical-align: top;
- color: #303030;
- background-color: #f0f0f0;
+ color: #333238;
+ background-color: #ececef;
}
.identicon.s16 {
font-size: 10px;
@@ -1644,7 +1649,7 @@ svg.s16 {
background-color: #fdf1dd;
}
.identicon.bg7 {
- background-color: #f0f0f0;
+ background-color: #ececef;
}
.avatar-container {
overflow: hidden;
@@ -1700,9 +1705,19 @@ svg.s16 {
.gl-display-none {
display: none;
}
+@media (min-width: 576px) {
+ .gl-sm-display-none {
+ display: none;
+ }
+}
.gl-display-flex {
display: flex;
}
+@media (min-width: 992px) {
+ .gl-lg-display-flex {
+ display: flex;
+ }
+}
@media (min-width: 576px) {
.gl-sm-display-block {
display: block;
@@ -1713,9 +1728,6 @@ svg.s16 {
display: block;
}
}
-.gl-display-inline-block\! {
- display: inline-block !important;
-}
.gl-align-items-center {
align-items: center;
}
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index 33e10b9bd62..7ae158b3930 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -22,7 +22,7 @@ body {
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
- color: #303030;
+ color: #333238;
text-align: left;
background-color: #fff;
}
@@ -110,7 +110,7 @@ h3 {
margin-bottom: 0.25rem;
font-weight: 600;
line-height: 1.2;
- color: #303030;
+ color: #333238;
}
h1 {
font-size: 2.1875rem;
@@ -196,24 +196,24 @@ hr {
font-size: 0.875rem;
font-weight: 400;
line-height: 1.5;
- color: #303030;
+ color: #333238;
background-color: #fff;
background-clip: padding-box;
- border: 1px solid #868686;
+ border: 1px solid #89888d;
border-radius: 0.25rem;
}
@media (prefers-reduced-motion: reduce) {
}
.form-control:-moz-focusring {
color: transparent;
- text-shadow: 0 0 0 #303030;
+ text-shadow: 0 0 0 #333238;
}
.form-control::placeholder {
- color: #5e5e5e;
+ color: #626168;
opacity: 1;
}
.form-control:disabled {
- background-color: #fafafa;
+ background-color: #fbfafd;
opacity: 1;
}
.form-group {
@@ -222,7 +222,7 @@ hr {
.btn {
display: inline-block;
font-weight: 400;
- color: #303030;
+ color: #333238;
text-align: center;
vertical-align: middle;
user-select: none;
@@ -282,10 +282,10 @@ input.btn-block[type="button"] {
border-color: #b3d7ff;
}
.custom-control-input:disabled ~ .custom-control-label {
- color: #5e5e5e;
+ color: #626168;
}
.custom-control-input:disabled ~ .custom-control-label::before {
- background-color: #fafafa;
+ background-color: #fbfafd;
}
.custom-control-label {
position: relative;
@@ -302,7 +302,7 @@ input.btn-block[type="button"] {
pointer-events: none;
content: "";
background-color: #fff;
- border: #666 solid 1px;
+ border: #737278 solid 1px;
}
.custom-control-label::after {
position: absolute;
@@ -400,8 +400,8 @@ input.btn-block[type="button"] {
padding-left: 0.75rem;
padding-right: 0.75rem;
height: auto;
- color: #303030;
- box-shadow: inset 0 0 0 1px #868686;
+ color: #333238;
+ box-shadow: inset 0 0 0 1px #89888d;
border-style: none;
appearance: none;
-moz-appearance: none;
@@ -410,27 +410,27 @@ input.btn-block[type="button"] {
.gl-form-input:not(.form-control-plaintext):not([type="color"]):read-only,
.gl-form-input.form-control:disabled,
.gl-form-input.form-control:not(.form-control-plaintext):not([type="color"]):read-only {
- background-color: #f5f5f5;
- box-shadow: inset 0 0 0 1px #dbdbdb;
+ background-color: #fbfafd;
+ box-shadow: inset 0 0 0 1px #dcdcde;
}
.gl-form-input:disabled,
.gl-form-input.form-control:disabled {
cursor: not-allowed;
- color: #666;
+ color: #737278;
}
.gl-form-input::placeholder,
.gl-form-input.form-control::placeholder {
- color: #868686;
+ color: #89888d;
}
.gl-form-checkbox {
font-size: 0.875rem;
line-height: 1rem;
- color: #303030;
+ color: #333238;
}
.gl-form-checkbox .custom-control-input:disabled,
.gl-form-checkbox .custom-control-input:disabled ~ .custom-control-label {
cursor: not-allowed;
- color: #868686;
+ color: #89888d;
}
.gl-form-checkbox.custom-control .custom-control-input ~ .custom-control-label {
cursor: pointer;
@@ -447,7 +447,7 @@ input.btn-block[type="button"] {
.custom-control-input
~ .custom-control-label::before {
background-color: #fff;
- border-color: #868686;
+ border-color: #89888d;
}
.gl-form-checkbox.custom-control
.custom-control-input:checked
@@ -490,8 +490,8 @@ input.btn-block[type="button"] {
.gl-form-checkbox.custom-control
.custom-control-input:disabled
~ .custom-control-label::before {
- background-color: #f0f0f0;
- border-color: #dbdbdb;
+ background-color: #ececef;
+ border-color: #dcdcde;
pointer-events: auto;
}
.gl-form-checkbox.custom-control
@@ -500,8 +500,8 @@ input.btn-block[type="button"] {
.gl-form-checkbox.custom-control
.custom-control-input[type="checkbox"]:indeterminate:disabled
~ .custom-control-label::before {
- background-color: #dbdbdb;
- border-color: #dbdbdb;
+ background-color: #dcdcde;
+ border-color: #dcdcde;
}
.gl-form-checkbox.custom-control
.custom-control-input:checked:disabled
@@ -509,7 +509,7 @@ input.btn-block[type="button"] {
.gl-form-checkbox.custom-control
.custom-control-input[type="checkbox"]:indeterminate:disabled
~ .custom-control-label::after {
- background-color: #5e5e5e;
+ background-color: #626168;
}
.gl-button {
display: inline-flex;
@@ -526,9 +526,9 @@ input.btn-block[type="button"] {
padding-right: 0.75rem;
background-color: transparent;
line-height: 1rem;
- color: #303030;
+ color: #333238;
fill: currentColor;
- box-shadow: inset 0 0 0 1px #bfbfbf;
+ box-shadow: inset 0 0 0 1px #bfbfc3;
justify-content: center;
align-items: center;
font-size: 0.875rem;
@@ -560,9 +560,9 @@ input.btn-block[type="button"] {
.gl-button.gl-button.btn-default.active,
.gl-button.gl-button.btn-block.btn-default:active,
.gl-button.gl-button.btn-block.btn-default.active {
- box-shadow: inset 0 0 0 1px #5e5e5e, 0 0 0 1px #fff, 0 0 0 3px #428fdc;
+ box-shadow: inset 0 0 0 1px #626168, 0 0 0 1px #fff, 0 0 0 3px #428fdc;
outline: none;
- background-color: #dbdbdb;
+ background-color: #dcdcde;
}
.gl-button.gl-button.btn-confirm,
.gl-button.gl-button.btn-block.btn-confirm {
@@ -636,20 +636,20 @@ body.navless {
font-weight: 400;
padding: 6px 10px;
background-color: #fff;
- border-color: #dbdbdb;
- color: #303030;
- color: #303030;
+ border-color: #dcdcde;
+ color: #333238;
+ color: #333238;
white-space: nowrap;
}
.btn:active {
- background-color: #f0f0f0;
+ background-color: #ececef;
box-shadow: none;
}
.btn:active,
.btn.active {
background-color: #eaeaea;
border-color: #e3e3e3;
- color: #303030;
+ color: #333238;
}
.btn svg {
height: 15px;
@@ -676,7 +676,7 @@ body.navless {
}
hr {
margin: 1.5rem 0;
- border-top: 1px solid #eee;
+ border-top: 1px solid #ececef;
}
.footer-links {
margin-bottom: 20px;
@@ -704,7 +704,7 @@ hr {
}
input {
border-radius: 0.25rem;
- color: #303030;
+ color: #333238;
background-color: #fff;
}
label {
@@ -721,7 +721,7 @@ label.label-bold {
padding: 6px 10px;
}
.form-control::placeholder {
- color: #868686;
+ color: #89888d;
}
.gl-show-field-errors .form-control:not(textarea) {
height: 34px;
@@ -730,7 +730,7 @@ label.label-bold {
justify-content: center;
height: var(--header-height, 48px);
background: #fff;
- border-bottom: 1px solid #dbdbdb;
+ border-bottom: 1px solid #dcdcde;
}
.navbar-empty .tanuki-logo,
.navbar-empty .brand-header-logo {
@@ -747,14 +747,14 @@ label.label-bold {
fill: #fca326;
}
input::-moz-placeholder {
- color: #868686;
+ color: #89888d;
opacity: 1;
}
input::-ms-input-placeholder {
- color: #868686;
+ color: #89888d;
}
input:-ms-input-placeholder {
- color: #868686;
+ color: #89888d;
}
svg {
fill: currentColor;
@@ -805,7 +805,7 @@ svg {
}
.login-page .login-box,
.login-page .omniauth-container {
- box-shadow: 0 0 0 1px #dbdbdb;
+ box-shadow: 0 0 0 1px #dcdcde;
border-radius: 0.25rem;
}
.login-page .login-box .login-heading h3,
@@ -863,7 +863,7 @@ svg {
}
.login-page .new-session-tabs {
display: flex;
- box-shadow: 0 0 0 1px #dbdbdb;
+ box-shadow: 0 0 0 1px #dcdcde;
border-top-right-radius: 4px;
border-top-left-radius: 4px;
}
@@ -874,7 +874,7 @@ svg {
.login-page .new-session-tabs.nav-links-unboxed .nav-item {
border-left: 0;
border-right: 0;
- border-bottom: 1px solid #dbdbdb;
+ border-bottom: 1px solid #dcdcde;
background-color: transparent;
}
.login-page .new-session-tabs.custom-provider-tabs {
@@ -885,7 +885,7 @@ svg {
flex-basis: auto;
}
.login-page .new-session-tabs.custom-provider-tabs li:nth-child(n + 5) {
- border-top: 1px solid #dbdbdb;
+ border-top: 1px solid #dcdcde;
}
.login-page .new-session-tabs.custom-provider-tabs a {
font-size: 16px;
@@ -893,7 +893,7 @@ svg {
.login-page .new-session-tabs li {
flex: 1;
text-align: center;
- border-left: 1px solid #dbdbdb;
+ border-left: 1px solid #dcdcde;
}
.login-page .new-session-tabs li:first-of-type {
border-left: 0;
@@ -903,7 +903,7 @@ svg {
border-top-right-radius: 4px;
}
.login-page .new-session-tabs li:not(.active) {
- background-color: #fafafa;
+ background-color: #fbfafd;
}
.login-page .new-session-tabs li a {
width: 100%;
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index 8e8cabbe511..a3474d2ed50 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -1,15 +1,15 @@
-$gray-10: #1f1f1f;
-$gray-50: #303030;
-$gray-100: #404040;
-$gray-200: #525252;
-$gray-300: #5e5e5e;
-$gray-400: #868686;
-$gray-500: #999;
-$gray-600: #bfbfbf;
-$gray-700: #dbdbdb;
-$gray-800: #f0f0f0;
-$gray-900: #fafafa;
-$gray-950: #fff;
+$gray-10: #1f1e24;
+$gray-50: #333238;
+$gray-100: #434248;
+$gray-200: #535158;
+$gray-300: #626168;
+$gray-400: #737278;
+$gray-500: #89888d;
+$gray-600: #a4a3a8;
+$gray-700: #bfbfc3;
+$gray-800: #dcdcde;
+$gray-900: #ececef;
+$gray-950: #fbfafd;
$green-50: #0a4020;
$green-100: #0d532a;
@@ -203,6 +203,7 @@ body.gl-dark {
--white: #{$white};
--black: #{$black};
+ --gray-light: #{$gray-50};
--svg-status-bg: #{$white};
@@ -255,9 +256,6 @@ $popover-arrow-outer-color: $gray-800;
$secondary: $gray-600;
-$issues-today-bg: #333838;
-$issues-today-border: #333a40;
-
$yiq-text-dark: $gray-50;
$yiq-text-light: $gray-950;
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index d644d8acc98..f37b426cd91 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -219,6 +219,16 @@
}
}
+ .search-sidebar {
+ .nav-link {
+ &.active,
+ &:hover {
+ background-color: rgba($gray-50, 0.8);
+ color: $gray-900;
+ }
+ }
+ }
+
// Sidebar
.nav-sidebar li.active > a {
color: $gray-900;
diff --git a/app/components/pajamas/avatar_component.rb b/app/components/pajamas/avatar_component.rb
index 073968e0491..423934b6887 100644
--- a/app/components/pajamas/avatar_component.rb
+++ b/app/components/pajamas/avatar_component.rb
@@ -17,10 +17,10 @@ module Pajamas
@avatar_options = avatar_options
end
- private
-
SIZE_OPTIONS = [16, 24, 32, 48, 64, 96].freeze
+ private
+
def avatar_classes
classes = ["gl-avatar", "gl-avatar-s#{@size}", @class]
classes.push("gl-avatar-circle") if @record.is_a?(User)
diff --git a/app/components/pajamas/badge_component.rb b/app/components/pajamas/badge_component.rb
index 244064b0e1e..4955bcd29ed 100644
--- a/app/components/pajamas/badge_component.rb
+++ b/app/components/pajamas/badge_component.rb
@@ -22,11 +22,11 @@ module Pajamas
@html_options = html_options
end
- private
-
SIZE_OPTIONS = [:sm, :md, :lg].freeze
VARIANT_OPTIONS = [:muted, :neutral, :info, :success, :warning, :danger].freeze
+ private
+
delegate :sprite_icon, to: :helpers
def badge_classes
diff --git a/app/components/pajamas/banner_component.rb b/app/components/pajamas/banner_component.rb
index 9b6343b47c9..6082762f22c 100644
--- a/app/components/pajamas/banner_component.rb
+++ b/app/components/pajamas/banner_component.rb
@@ -23,13 +23,15 @@ module Pajamas
@button_text = button_text
@button_link = button_link
@embedded = embedded
- @variant = variant.to_sym
+ @variant = filter_attribute(variant.to_sym, VARIANT_OPTIONS, default: :promotion)
@svg_path = svg_path.to_s
@banner_options = banner_options
@button_options = button_options
@close_options = close_options
end
+ VARIANT_OPTIONS = [:introduction, :promotion].freeze
+
private
def banner_class
diff --git a/app/components/pajamas/spinner_component.html.haml b/app/components/pajamas/spinner_component.html.haml
index aab9c5fdbf7..b319f3b1632 100644
--- a/app/components/pajamas/spinner_component.html.haml
+++ b/app/components/pajamas/spinner_component.html.haml
@@ -1,5 +1,2 @@
-.gl-spinner-container{ class: @class }
- - if @inline
- %span{ class: spinner_class, aria: {label: @label} }
- - else
- %div{ class: spinner_class, aria: {label: @label} }
+= content_tag (@inline ? :span : :div), **html_options do
+ %span{ class: spinner_class, aria: {label: @label} }>
diff --git a/app/components/pajamas/spinner_component.rb b/app/components/pajamas/spinner_component.rb
index c7ffc1ec3da..f2f7236ee3f 100644
--- a/app/components/pajamas/spinner_component.rb
+++ b/app/components/pajamas/spinner_component.rb
@@ -2,26 +2,31 @@
module Pajamas
class SpinnerComponent < Pajamas::Component
- # @param [String] class
# @param [Symbol] color
# @param [Boolean] inline
# @param [String] label
# @param [Symbol] size
- def initialize(class: '', color: :dark, inline: false, label: _("Loading"), size: :sm)
- @class = binding.local_variable_get(:class)
+ def initialize(color: :dark, inline: false, label: _("Loading"), size: :sm, **html_options)
@color = filter_attribute(color.to_sym, COLOR_OPTIONS)
@inline = inline
@label = label.presence
@size = filter_attribute(size.to_sym, SIZE_OPTIONS)
+ @html_options = html_options
end
+ COLOR_OPTIONS = [:light, :dark].freeze
+ SIZE_OPTIONS = [:sm, :md, :lg, :xl].freeze
+
private
def spinner_class
- ["gl-spinner", "gl-spinner-#{@size}", "gl-spinner-#{@color}"]
+ ["gl-spinner", "gl-spinner-#{@size}", "gl-spinner-#{@color} gl-vertical-align-text-bottom!"]
end
- COLOR_OPTIONS = [:light, :dark].freeze
- SIZE_OPTIONS = [:sm, :md, :lg, :xl].freeze
+ def html_options
+ options = format_options(options: @html_options, css_classes: "gl-spinner-container")
+ options[:role] = "status"
+ options
+ end
end
end
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index b75a7c4a2dd..ec9441c2b9b 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -13,10 +13,6 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
before_action :disable_query_limiting, only: [:usage_data]
- before_action do
- push_frontend_feature_flag(:ci_variable_settings_graphql)
- end
-
feature_category :not_owned, [ # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
:general, :reporting, :metrics_and_profiling, :network,
:preferences, :update, :reset_health_check_token
@@ -84,7 +80,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
format.json do
Gitlab::UsageDataCounters::ServiceUsageDataCounter.count(:download_payload_click)
- render json: service_ping_data.to_json
+ render json: Gitlab::Json.dump(service_ping_data)
end
end
end
diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb
index edd85414696..bdf0c6aedb9 100644
--- a/app/controllers/admin/broadcast_messages_controller.rb
+++ b/app/controllers/admin/broadcast_messages_controller.rb
@@ -3,39 +3,60 @@
class Admin::BroadcastMessagesController < Admin::ApplicationController
include BroadcastMessagesHelper
- before_action :finder, only: [:edit, :update, :destroy]
+ 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
- # rubocop: disable CodeReuse/ActiveRecord
def index
- push_frontend_feature_flag(:vue_broadcast_messages, current_user)
- push_frontend_feature_flag(:role_targeted_broadcast_messages, current_user)
-
- @broadcast_messages = BroadcastMessage.order(ends_at: :desc).page(params[:page])
- @broadcast_message = BroadcastMessage.new
+ @broadcast_message = BroadcastMessage.new
end
- # rubocop: enable CodeReuse/ActiveRecord
def edit
end
def create
@broadcast_message = BroadcastMessage.new(broadcast_message_params)
+ success = @broadcast_message.save
- if @broadcast_message.save
- redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully created.')
- else
- render :index
+ respond_to do |format|
+ format.json do
+ if success
+ render json: @broadcast_message, status: :ok
+ else
+ render json: { errors: @broadcast_message.errors.full_messages }, status: :bad_request
+ end
+ end
+ format.html do
+ if success
+ redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully created.')
+ else
+ render :index
+ end
+ end
end
end
def update
- if @broadcast_message.update(broadcast_message_params)
- redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully updated.')
- else
- render :edit
+ success = @broadcast_message.update(broadcast_message_params)
+
+ respond_to do |format|
+ format.json do
+ if success
+ render json: @broadcast_message, status: :ok
+ else
+ render json: { errors: @broadcast_message.errors.full_messages }, status: :bad_request
+ end
+ end
+ format.html do
+ if success
+ redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully updated.')
+ else
+ render :edit
+ end
+ end
end
end
@@ -55,10 +76,14 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController
protected
- def finder
+ def find_broadcast_message
@broadcast_message = BroadcastMessage.find(params[:id])
end
+ def find_broadcast_messages
+ @broadcast_messages = BroadcastMessage.order(ends_at: :desc).page(params[:page]) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
def broadcast_message_params
params.require(:broadcast_message)
.permit(%i(
@@ -71,4 +96,9 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController
dismissable
), target_access_levels: []).reverse_merge!(target_access_levels: [])
end
+
+ def push_features
+ push_frontend_feature_flag(:vue_broadcast_messages, current_user)
+ push_frontend_feature_flag(:role_targeted_broadcast_messages, current_user)
+ end
end
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index f3c4244269d..1395d4bb3b7 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -38,12 +38,9 @@ class Admin::GroupsController < Admin::ApplicationController
end
def create
- @group = Group.new(group_params)
- @group.name = @group.path.dup unless @group.name
+ @group = ::Groups::CreateService.new(current_user, group_params).execute
- if @group.save
- @group.add_owner(current_user)
- @group.create_namespace_settings
+ if @group.persisted?
redirect_to [:admin, @group], notice: _('Group %{group_name} was successfully created.') % { group_name: @group.name }
else
render "new"
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 1a57d271271..2c8b4888d5d 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -26,6 +26,8 @@ class Admin::UsersController < Admin::ApplicationController
end
def show
+ @can_impersonate = can_impersonate_user
+ @impersonation_error_text = @can_impersonate ? nil : impersonation_error_text
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -47,7 +49,7 @@ class Admin::UsersController < Admin::ApplicationController
end
def impersonate
- if can?(user, :log_in) && !impersonation_in_progress?
+ if can_impersonate_user
session[:impersonator_id] = current_user.id
warden.set_user(user, scope: :user)
@@ -59,16 +61,7 @@ class Admin::UsersController < Admin::ApplicationController
redirect_to root_path
else
- flash[:alert] =
- if impersonation_in_progress?
- _("You are already impersonating another user")
- elsif user.blocked?
- _("You cannot impersonate a blocked user")
- elsif user.internal?
- _("You cannot impersonate an internal user")
- else
- _("You cannot impersonate a user who cannot log in")
- end
+ flash[:alert] = impersonation_error_text
redirect_to admin_user_path(user)
end
@@ -378,6 +371,24 @@ class Admin::UsersController < Admin::ApplicationController
def log_impersonation_event
Gitlab::AppLogger.info(_("User %{current_user_username} has started impersonating %{username}") % { current_user_username: current_user.username, username: user.username })
end
+
+ def can_impersonate_user
+ can?(user, :log_in) && !user.password_expired? && !impersonation_in_progress?
+ end
+
+ def impersonation_error_text
+ if impersonation_in_progress?
+ _("You are already impersonating another user")
+ elsif user.blocked?
+ _("You cannot impersonate a blocked user")
+ elsif user.password_expired?
+ _("You cannot impersonate a user with an expired password")
+ elsif user.internal?
+ _("You cannot impersonate an internal user")
+ else
+ _("You cannot impersonate a user who cannot log in")
+ end
+ end
end
Admin::UsersController.prepend_mod_with('Admin::UsersController')
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 84efb8b0da8..4de6b5de42a 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -13,6 +13,7 @@ class ApplicationController < ActionController::Base
include EnforcesTwoFactorAuthentication
include WithPerformanceBar
include Gitlab::SearchContext::ControllerConcern
+ include PreferredLanguageSwitcher
include SessionlessAuthentication
include SessionsHelper
include ConfirmEmailWarning
@@ -512,7 +513,13 @@ class ApplicationController < ActionController::Base
end
def set_locale(&block)
- Gitlab::I18n.with_user_locale(current_user, &block)
+ return Gitlab::I18n.with_user_locale(current_user, &block) unless Feature.enabled?(:preferred_language_switcher)
+
+ if current_user
+ Gitlab::I18n.with_user_locale(current_user, &block)
+ else
+ Gitlab::I18n.with_locale(preferred_language, &block)
+ end
end
def set_session_storage(&block)
diff --git a/app/controllers/concerns/access_tokens_actions.rb b/app/controllers/concerns/access_tokens_actions.rb
index 6e43be5594d..fdb08c6572f 100644
--- a/app/controllers/concerns/access_tokens_actions.rb
+++ b/app/controllers/concerns/access_tokens_actions.rb
@@ -13,6 +13,13 @@ module AccessTokensActions
def index
@resource_access_token = PersonalAccessToken.new
set_index_vars
+
+ respond_to do |format|
+ format.html
+ format.json do
+ render json: @active_access_tokens
+ end
+ end
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
@@ -23,7 +30,7 @@ module AccessTokensActions
if token_response.success?
@resource_access_token = token_response.payload[:access_token]
render json: { new_token: @resource_access_token.token,
- active_access_tokens: active_resource_access_tokens }, status: :ok
+ active_access_tokens: active_access_tokens }, status: :ok
else
render json: { errors: token_response.errors }, status: :unprocessable_entity
end
@@ -62,15 +69,10 @@ module AccessTokensActions
resource.members.load
@scopes = Gitlab::Auth.resource_bot_scopes
- @active_resource_access_tokens = active_resource_access_tokens
+ @active_access_tokens = active_access_tokens
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
- def active_resource_access_tokens
- tokens = finder(state: 'active', sort: 'expires_at_asc_id_desc').execute.preload_users
- represent(tokens)
- end
-
def finder(options = {})
PersonalAccessTokensFinder.new({ user: bot_users, impersonation: false }.merge(options))
end
diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb
index fbaa754124c..817f82085e6 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -137,7 +137,7 @@ module AuthenticatesWithTwoFactor
session[:credentialRequestOptions] = get_options
session[:challenge] = get_options.challenge
- gon.push(webauthn: { options: get_options.to_json })
+ gon.push(webauthn: { options: Gitlab::Json.dump(get_options) })
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb
index c3ad9d3dff3..30de4a86bec 100644
--- a/app/controllers/concerns/integrations/params.rb
+++ b/app/controllers/concerns/integrations/params.rb
@@ -88,6 +88,10 @@ module Integrations
param_values = return_value[:integration]
if param_values.is_a?(ActionController::Parameters)
+ if action_name == 'update' && integration.chat? && param_values['webhook'] == BaseChatNotification::SECRET_MASK
+ param_values.delete('webhook')
+ end
+
integration.secret_fields.each do |param|
param_values.delete(param) if param_values[param].blank?
end
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 7c3401a7e90..bea184e44b9 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -142,43 +142,21 @@ module IssuableActions
end
end
- # rubocop:disable CodeReuse/ActiveRecord
def discussions
- notes = NotesFinder.new(current_user, finder_params_for_issuable).execute
- .inc_relations_for_view
- .includes(:noteable)
- .fresh
+ finder = Issuable::DiscussionsListService.new(current_user, issuable, finder_params_for_issuable)
+ discussion_notes = finder.execute
- if paginated_discussions
- paginated_discussions_by_type = paginated_discussions.records.group_by(&:table_name)
+ response.headers['X-Next-Page-Cursor'] = finder.paginator.cursor_for_next_page if finder.paginator.present? && finder.paginator.has_next_page?
- notes = if paginated_discussions_by_type['notes'].present?
- notes.with_discussion_ids(paginated_discussions_by_type['notes'].map(&:discussion_id))
- else
- notes.none
- end
-
- response.headers['X-Next-Page-Cursor'] = paginated_discussions.cursor_for_next_page if paginated_discussions.has_next_page?
- end
-
- if notes_filter != UserPreference::NOTES_FILTERS[:only_comments]
- notes = ResourceEvents::MergeIntoNotesService.new(issuable, current_user, paginated_notes: paginated_discussions_by_type).execute(notes)
- end
-
- notes = prepare_notes_for_rendering(notes)
- notes = notes.select { |n| n.readable_by?(current_user) }
-
- discussions = Discussion.build_collection(notes, issuable)
-
- if issuable.is_a?(MergeRequest)
- render_mr_discussions(discussions, discussion_serializer, discussion_cache_context)
- elsif issuable.is_a?(Issue)
- render json: discussion_serializer.represent(discussions, context: self) if stale?(etag: [discussion_cache_context, discussions])
+ case issuable
+ when MergeRequest
+ render_mr_discussions(discussion_notes, discussion_serializer, discussion_cache_context)
+ when Issue
+ render json: discussion_serializer.represent(discussion_notes, context: self) if stale?(etag: [discussion_cache_context, discussion_notes])
else
- render json: discussion_serializer.represent(discussions, context: self)
+ render json: discussion_serializer.represent(discussion_notes, context: self)
end
end
- # rubocop:enable CodeReuse/ActiveRecord
private
@@ -199,17 +177,6 @@ module IssuableActions
context: self)
end
- def paginated_discussions
- return if params[:per_page].blank?
- return if issuable.instance_of?(MergeRequest) && Feature.disabled?(:paginated_mr_discussions, project)
-
- strong_memoize(:paginated_discussions) do
- issuable
- .discussion_root_note_ids(notes_filter: notes_filter)
- .keyset_paginate(cursor: params[:cursor], per_page: params[:per_page].to_i)
- end
- end
-
def notes_filter
strong_memoize(:notes_filter) do
notes_filter_param = params[:notes_filter]&.to_i
@@ -325,9 +292,10 @@ module IssuableActions
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def finder_params_for_issuable
{
- target: @issuable,
- notes_filter: notes_filter
- }.tap { |new_params| new_params[:project] = project if respond_to?(:project, true) }
+ notes_filter: notes_filter,
+ cursor: params[:cursor],
+ per_page: params[:per_page]
+ }
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
end
diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb
index e03d1de7bf9..7beb86b51fd 100644
--- a/app/controllers/concerns/issuable_collections_action.rb
+++ b/app/controllers/concerns/issuable_collections_action.rb
@@ -60,7 +60,6 @@ module IssuableCollectionsAction
def finder_options
issue_types = Issue::TYPES_FOR_LIST
- issue_types = issue_types.excluding('task') unless Feature.enabled?(:work_items)
super.merge(
non_archived: true,
diff --git a/app/controllers/concerns/preferred_language_switcher.rb b/app/controllers/concerns/preferred_language_switcher.rb
new file mode 100644
index 00000000000..9711e57cf7a
--- /dev/null
+++ b/app/controllers/concerns/preferred_language_switcher.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module PreferredLanguageSwitcher
+ extend ActiveSupport::Concern
+
+ private
+
+ def init_preferred_language
+ return unless Feature.enabled?(:preferred_language_switcher)
+
+ cookies[:preferred_language] = preferred_language
+ end
+
+ def preferred_language
+ cookies[:preferred_language].presence_in(Gitlab::I18n.available_locales) ||
+ Gitlab::CurrentSettings.default_preferred_language
+ end
+end
diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb
index 79b3fa28660..7af114313a1 100644
--- a/app/controllers/concerns/preview_markdown.rb
+++ b/app/controllers/concerns/preview_markdown.rb
@@ -41,7 +41,7 @@ module PreviewMarkdown
case controller_name
when 'wikis' then { pipeline: :wiki, wiki: wiki, page_slug: params[:id] }
when 'snippets' then { skip_project_check: true }
- when 'groups' then { group: group }
+ when 'groups' then { group: group, issuable_reference_expansion_enabled: true }
when 'projects' then projects_filter_params
when 'timeline_events' then timeline_events_filter_params
else {}
diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb
index 4f96cc5c895..dfa159ccfd7 100644
--- a/app/controllers/concerns/product_analytics_tracking.rb
+++ b/app/controllers/concerns/product_analytics_tracking.rb
@@ -6,6 +6,8 @@ module ProductAnalyticsTracking
extend ActiveSupport::Concern
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]
diff --git a/app/controllers/concerns/render_access_tokens.rb b/app/controllers/concerns/render_access_tokens.rb
new file mode 100644
index 00000000000..b0bbad7e37f
--- /dev/null
+++ b/app/controllers/concerns/render_access_tokens.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+module RenderAccessTokens
+ extend ActiveSupport::Concern
+
+ def active_access_tokens
+ tokens = finder(state: 'active', sort: 'expires_at_asc_id_desc').execute.preload_users
+
+ if Feature.enabled?('access_token_pagination')
+ tokens = tokens.page(page)
+ add_pagination_headers(tokens)
+ end
+
+ represent(tokens)
+ end
+
+ def add_pagination_headers(relation)
+ Gitlab::Pagination::OffsetHeaderBuilder.new(
+ request_context: self,
+ per_page: relation.limit_value,
+ page: relation.current_page,
+ next_page: relation.next_page,
+ prev_page: relation.prev_page,
+ total: relation.total_count,
+ params: params.permit(:page, :per_page)
+ ).execute
+ end
+
+ def page
+ (params[:page] || 1).to_i
+ end
+end
diff --git a/app/controllers/concerns/send_file_upload.rb b/app/controllers/concerns/send_file_upload.rb
index c8369c465b8..c91edb74d6b 100644
--- a/app/controllers/concerns/send_file_upload.rb
+++ b/app/controllers/concerns/send_file_upload.rb
@@ -30,7 +30,17 @@ module SendFileUpload
headers.store(*Gitlab::Workhorse.send_url(file_upload.url(**redirect_params)))
head :ok
else
- redirect_to file_upload.url(**redirect_params)
+ redirect_to cdn_fronted_url(file_upload, redirect_params)
+ end
+ end
+
+ def cdn_fronted_url(file, redirect_params)
+ if file.respond_to?(:cdn_enabled_url)
+ result = file.cdn_enabled_url(request.remote_ip, redirect_params[:query])
+ Gitlab::ApplicationContext.push(artifact_used_cdn: result.used_cdn)
+ result.url
+ else
+ file.url(**redirect_params)
end
end
diff --git a/app/controllers/concerns/verifies_with_email.rb b/app/controllers/concerns/verifies_with_email.rb
index 782cae53c3f..ac1475597ff 100644
--- a/app/controllers/concerns/verifies_with_email.rb
+++ b/app/controllers/concerns/verifies_with_email.rb
@@ -8,7 +8,7 @@ module VerifiesWithEmail
include ActionView::Helpers::DateHelper
included do
- prepend_before_action :verify_with_email, only: :create, unless: -> { two_factor_enabled? }
+ prepend_before_action :verify_with_email, only: :create, unless: -> { skip_verify_with_email? }
skip_before_action :required_signup_info, only: :successful_verification
end
@@ -55,6 +55,10 @@ module VerifiesWithEmail
private
+ def skip_verify_with_email?
+ two_factor_enabled? || Gitlab::Qa.request?(request.user_agent)
+ end
+
def find_verification_user
return unless session[:verification_user_id]
@@ -84,10 +88,7 @@ module VerifiesWithEmail
def send_verification_instructions_email(user, token)
return unless user.can?(:receive_notifications)
- Notify.verification_instructions_email(
- user.id,
- token: token,
- expires_in: Users::EmailVerification::ValidateTokenService::TOKEN_VALID_FOR_MINUTES).deliver_later
+ Notify.verification_instructions_email(user.email, token: token).deliver_later
log_verification(user, :instructions_sent)
end
diff --git a/app/controllers/concerns/web_hooks/hook_actions.rb b/app/controllers/concerns/web_hooks/hook_actions.rb
index ea11f13c7ef..75065ef9d24 100644
--- a/app/controllers/concerns/web_hooks/hook_actions.rb
+++ b/app/controllers/concerns/web_hooks/hook_actions.rb
@@ -20,7 +20,7 @@ module WebHooks
unless hook.valid?
self.hooks = relation.select(&:persisted?)
- flash[:alert] = hook.errors.full_messages.join.html_safe
+ flash[:alert] = hook.errors.full_messages.to_sentence.html_safe
end
redirect_to action: :index
@@ -53,6 +53,8 @@ module WebHooks
ps = params.require(:hook).permit(*permitted).to_h
+ ps.delete(:token) if action_name == 'update' && ps[:token] == WebHook::SECRET_MASK
+
ps[:url_variables] = ps[:url_variables].to_h { [_1[:key], _1[:value].presence] } if ps.key?(:url_variables)
if action_name == 'update' && ps.key?(:url_variables)
@@ -64,7 +66,9 @@ module WebHooks
end
def hook_param_names
- %i[enable_ssl_verification token url push_events_branch_filter]
+ param_names = %i[enable_ssl_verification token url push_events_branch_filter]
+ param_names.push(:branch_filter_strategy) if Feature.enabled?(:enhanced_webhook_support_regex)
+ param_names
end
def destroy_hook(hook)
diff --git a/app/controllers/concerns/web_hooks/hook_execution_notice.rb b/app/controllers/concerns/web_hooks/hook_execution_notice.rb
index d651313b30d..69b140723e3 100644
--- a/app/controllers/concerns/web_hooks/hook_execution_notice.rb
+++ b/app/controllers/concerns/web_hooks/hook_execution_notice.rb
@@ -5,7 +5,7 @@ module WebHooks
private
def set_hook_execution_notice(result)
- http_status = result[:http_status]
+ http_status = result.payload[:http_status]
message = result[:message]
if http_status && http_status >= 200 && http_status < 400
diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb
index 713231cbc6f..6dd4d72bbc7 100644
--- a/app/controllers/confirmations_controller.rb
+++ b/app/controllers/confirmations_controller.rb
@@ -6,6 +6,7 @@ class ConfirmationsController < Devise::ConfirmationsController
include OneTrustCSP
include GoogleAnalyticsCSP
+ skip_before_action :required_signup_info
prepend_before_action :check_recaptcha, only: :create
before_action :load_recaptcha, only: :new
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index 0e4592259d8..89d362c88a4 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -66,11 +66,10 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
end
def load_projects(finder_params)
- @total_user_projects_count = ProjectsFinder.new(params: { non_public: true, without_deleted: true, not_aimed_for_deletion: true }, current_user: current_user).execute
- @total_starred_projects_count = ProjectsFinder.new(params: { starred: true, without_deleted: true, not_aimed_for_deletion: true }, current_user: current_user).execute
+ @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
finder_params[:use_cte] = true if use_cte_for_finder?
- finder_params[:without_deleted] = true
projects = ProjectsFinder.new(params: finder_params, current_user: current_user).execute
@@ -93,7 +92,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
def load_events
projects = ProjectsFinder
- .new(params: params.merge(non_public: true, without_deleted: true), current_user: current_user)
+ .new(params: params.merge(non_public: true, not_aimed_for_deletion: true), current_user: current_user)
.execute
@events = EventCollection
diff --git a/app/controllers/explore/groups_controller.rb b/app/controllers/explore/groups_controller.rb
index 97791b43d41..ac355b861b3 100644
--- a/app/controllers/explore/groups_controller.rb
+++ b/app/controllers/explore/groups_controller.rb
@@ -7,6 +7,8 @@ class Explore::GroupsController < Explore::ApplicationController
urgency :low
def index
- render_group_tree GroupsFinder.new(current_user).execute
+ user = Feature.enabled?(:generic_explore_groups, current_user, type: :experiment) ? nil : current_user
+
+ render_group_tree GroupsFinder.new(user).execute
end
end
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index 67eeb43d5a2..5ffd525c170 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -190,7 +190,8 @@ class GraphqlController < ApplicationController
current_user: current_user,
is_sessionless_user: api_user,
request: request,
- scope_validator: ::Gitlab::Auth::ScopeValidator.new(api_user, request_authenticator)
+ scope_validator: ::Gitlab::Auth::ScopeValidator.new(api_user, request_authenticator),
+ remove_deprecated: Gitlab::Utils.to_boolean(params[:remove_deprecated], default: false)
}
end
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index 14b70df0feb..e1ba86220c7 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -7,7 +7,7 @@ class Groups::BoardsController < Groups::ApplicationController
before_action do
push_frontend_feature_flag(:board_multi_select, group)
- push_frontend_feature_flag(:realtime_labels, group)
+ push_frontend_feature_flag(:apollo_boards, group)
experiment(:prominent_create_board_btn, subject: current_user) do |e|
e.control {}
e.candidate {}
diff --git a/app/controllers/groups/dependency_proxy/application_controller.rb b/app/controllers/groups/dependency_proxy/application_controller.rb
index f7337a3cdb1..300a82eed78 100644
--- a/app/controllers/groups/dependency_proxy/application_controller.rb
+++ b/app/controllers/groups/dependency_proxy/application_controller.rb
@@ -21,10 +21,11 @@ module Groups
user_or_deploy_token = ::DependencyProxy::AuthTokenService.user_or_deploy_token_from_jwt(token)
- if user_or_deploy_token.is_a?(User)
+ case user_or_deploy_token
+ when User
@authentication_result = Gitlab::Auth::Result.new(user_or_deploy_token, nil, :user, [])
sign_in(user_or_deploy_token)
- elsif user_or_deploy_token.is_a?(DeployToken)
+ when DeployToken
@authentication_result = Gitlab::Auth::Result.new(user_or_deploy_token, nil, :deploy_token, [])
end
end
diff --git a/app/controllers/groups/observability_controller.rb b/app/controllers/groups/observability_controller.rb
index 5b6503494c4..4b1f2b582ce 100644
--- a/app/controllers/groups/observability_controller.rb
+++ b/app/controllers/groups/observability_controller.rb
@@ -9,35 +9,41 @@ module Groups
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 'self'
- frame_src_values = Array.wrap(default_frame_src) | [ObservabilityController.observability_url, "'self'"]
+ frame_src_values = Array.wrap(default_frame_src) | [observability_url, "'self'"]
p.frame_src(*frame_src_values)
end
- before_action :check_observability_allowed, only: :index
+ before_action :check_observability_allowed
- def index
- # Format: https://observe.gitlab.com/-/GROUP_ID
- @observability_iframe_src = "#{ObservabilityController.observability_url}/-/#{@group.id}"
+ def dashboards
+ render_observability
+ end
- # Uncomment below for testing with local GDK
- # @observability_iframe_src = "#{ObservabilityController.observability_url}/9970?groupId=14485840"
+ def manage
+ render_observability
+ end
- render layout: 'group', locals: { base_layout: 'layouts/fullscreen' }
+ def explore
+ render_observability
end
private
+ def render_observability
+ render 'observability', layout: 'group', locals: { base_layout: 'layouts/fullscreen' }
+ end
+
def self.observability_url
- return ENV['OVERRIDE_OBSERVABILITY_URL'] if ENV['OVERRIDE_OBSERVABILITY_URL']
- # TODO Make observability URL configurable https://gitlab.com/gitlab-org/opstrace/opstrace-ui/-/issues/80
- return "https://staging.observe.gitlab.com" if Gitlab.staging?
+ Gitlab::Observability.observability_url
+ end
- "https://observe.gitlab.com"
+ def observability_url
+ self.class.observability_url
end
def check_observability_allowed
- return render_404 unless self.class.observability_url.present?
+ return render_404 unless observability_url.present?
render_404 unless can?(current_user, :read_observability, @group)
end
diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb
index bb2d08e487a..cb7bf001918 100644
--- a/app/controllers/groups/registry/repositories_controller.rb
+++ b/app/controllers/groups/registry/repositories_controller.rb
@@ -8,10 +8,6 @@ module Groups
before_action :verify_container_registry_enabled!
before_action :authorize_read_container_image!
- before_action do
- push_frontend_feature_flag(:container_registry_show_shortened_path, group)
- end
-
feature_category :container_registry
urgency :low
diff --git a/app/controllers/groups/settings/access_tokens_controller.rb b/app/controllers/groups/settings/access_tokens_controller.rb
index f01b2b779e3..d86ddcfe2d0 100644
--- a/app/controllers/groups/settings/access_tokens_controller.rb
+++ b/app/controllers/groups/settings/access_tokens_controller.rb
@@ -3,6 +3,7 @@
module Groups
module Settings
class AccessTokensController < Groups::ApplicationController
+ include RenderAccessTokens
include AccessTokensActions
layout 'group_settings'
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index e164a834519..b1afac1f1c7 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -10,9 +10,6 @@ module Groups
before_action :define_variables, only: [:show]
before_action :push_licensed_features, only: [:show]
before_action :assign_variables_to_gon, only: [:show]
- before_action do
- push_frontend_feature_flag(:ci_variable_settings_graphql, @group)
- end
feature_category :continuous_integration
urgency :low
diff --git a/app/controllers/groups/settings/packages_and_registries_controller.rb b/app/controllers/groups/settings/packages_and_registries_controller.rb
index 411b8577c3f..ec4a0b312ee 100644
--- a/app/controllers/groups/settings/packages_and_registries_controller.rb
+++ b/app/controllers/groups/settings/packages_and_registries_controller.rb
@@ -7,6 +7,10 @@ module Groups
before_action :authorize_admin_group!
before_action :verify_packages_enabled!
+ before_action do
+ push_frontend_feature_flag(:maven_central_request_forwarding, group)
+ end
+
feature_category :package_registry
urgency :low
diff --git a/app/controllers/groups/settings/repository_controller.rb b/app/controllers/groups/settings/repository_controller.rb
index cb62ea2a543..ecd5d814fb6 100644
--- a/app/controllers/groups/settings/repository_controller.rb
+++ b/app/controllers/groups/settings/repository_controller.rb
@@ -8,9 +8,6 @@ module Groups
before_action :authorize_create_deploy_token!, only: :create_deploy_token
before_action :authorize_access!, only: :show
before_action :define_deploy_token_variables, if: -> { can?(current_user, :create_deploy_token, @group) }
- before_action do
- push_frontend_feature_flag(:ajax_new_deploy_token, @group)
- end
feature_category :continuous_delivery
urgency :low
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 269342a6c22..3f516c24a69 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -35,6 +35,7 @@ class GroupsController < Groups::ApplicationController
before_action :track_experiment_event, only: [:new]
before_action only: :issues do
+ push_frontend_feature_flag(:or_issuable_queries, group)
push_force_frontend_feature_flag(:work_items, group.work_items_feature_flag_enabled?)
end
@@ -111,7 +112,7 @@ class GroupsController < Groups::ApplicationController
def details
respond_to do |format|
format.html do
- render_details_html
+ redirect_to group_path(group)
end
format.atom do
@@ -235,10 +236,6 @@ class GroupsController < Groups::ApplicationController
render 'groups/show', locals: { trial: params[:trial] }
end
- def render_details_html
- render 'groups/show'
- end
-
def render_details_view_atom
load_events
render layout: 'xml', template: 'groups/show'
diff --git a/app/controllers/jira_connect/application_controller.rb b/app/controllers/jira_connect/application_controller.rb
index e26d69314cd..b9f0ea795e1 100644
--- a/app/controllers/jira_connect/application_controller.rb
+++ b/app/controllers/jira_connect/application_controller.rb
@@ -3,6 +3,11 @@
class JiraConnect::ApplicationController < ApplicationController
include Gitlab::Utils::StrongMemoize
+ CORS_ALLOWED_METHODS = {
+ '/-/jira_connect/oauth_application_id' => %i[GET OPTIONS],
+ '/-/jira_connect/subscriptions/*' => %i[DELETE OPTIONS]
+ }.freeze
+
skip_before_action :authenticate_user!
skip_before_action :verify_authenticity_token
before_action :verify_atlassian_jwt!
@@ -60,4 +65,25 @@ class JiraConnect::ApplicationController < ApplicationController
def auth_token
params[:jwt] || request.headers['Authorization']&.split(' ', 2)&.last
end
+
+ def cors_allowed_methods
+ CORS_ALLOWED_METHODS[resource]
+ end
+
+ def resource
+ request.path.gsub(%r{/\d+$}, '/*')
+ end
+
+ def set_cors_headers
+ return unless allow_cors_request?
+
+ response.set_header('Access-Control-Allow-Origin', Gitlab::CurrentSettings.jira_connect_proxy_url)
+ response.set_header('Access-Control-Allow-Methods', cors_allowed_methods.join(', '))
+ end
+
+ def allow_cors_request?
+ return false if cors_allowed_methods.nil?
+
+ !Gitlab.com? && Gitlab::CurrentSettings.jira_connect_proxy_url.present?
+ end
end
diff --git a/app/controllers/jira_connect/cors_preflight_checks_controller.rb b/app/controllers/jira_connect/cors_preflight_checks_controller.rb
new file mode 100644
index 00000000000..3f30c1e04df
--- /dev/null
+++ b/app/controllers/jira_connect/cors_preflight_checks_controller.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module JiraConnect
+ class CorsPreflightChecksController < ApplicationController
+ feature_category :integrations
+
+ skip_before_action :verify_atlassian_jwt!
+ before_action :set_cors_headers
+
+ def index
+ return render_404 unless allow_cors_request?
+
+ render plain: '', content_type: 'text/plain'
+ end
+ end
+end
diff --git a/app/controllers/jira_connect/oauth_application_ids_controller.rb b/app/controllers/jira_connect/oauth_application_ids_controller.rb
index a84b47f4c8b..3e788e2282e 100644
--- a/app/controllers/jira_connect/oauth_application_ids_controller.rb
+++ b/app/controllers/jira_connect/oauth_application_ids_controller.rb
@@ -1,11 +1,11 @@
# frozen_string_literal: true
module JiraConnect
- class OauthApplicationIdsController < ::ApplicationController
+ class OauthApplicationIdsController < ApplicationController
feature_category :integrations
- skip_before_action :authenticate_user!
- skip_before_action :verify_authenticity_token
+ skip_before_action :verify_atlassian_jwt!
+ before_action :set_cors_headers
def show
if show_application_id?
diff --git a/app/controllers/jira_connect/subscriptions_controller.rb b/app/controllers/jira_connect/subscriptions_controller.rb
index 9305f46c39e..9a732cadd94 100644
--- a/app/controllers/jira_connect/subscriptions_controller.rb
+++ b/app/controllers/jira_connect/subscriptions_controller.rb
@@ -27,6 +27,7 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
before_action :verify_qsh_claim!, only: :index
before_action :allow_self_managed_content_security_policy, only: :index
before_action :authenticate_user!, only: :create
+ before_action :set_cors_headers
def index
@subscriptions = current_jira_installation.subscriptions.preload_namespace_route
@@ -64,7 +65,7 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
private
def allow_self_managed_content_security_policy
- return unless Feature.enabled?(:jira_connect_oauth_self_managed)
+ return unless Feature.enabled?(:jira_connect_oauth_self_managed_setting)
return unless current_jira_installation.instance_url?
@@ -83,7 +84,8 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
def allowed_instance_connect_src
[
Gitlab::Utils.append_path(current_jira_installation.instance_url, '/-/jira_connect/'),
- Gitlab::Utils.append_path(current_jira_installation.instance_url, '/api/')
+ Gitlab::Utils.append_path(current_jira_installation.instance_url, '/api/'),
+ Gitlab::Utils.append_path(current_jira_installation.instance_url, '/oauth/token')
]
end
end
diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb
index bf8b61db2e5..43bf895ea76 100644
--- a/app/controllers/oauth/authorizations_controller.rb
+++ b/app/controllers/oauth/authorizations_controller.rb
@@ -4,7 +4,7 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
include InitializesCurrentUserMode
include Gitlab::Utils::StrongMemoize
- before_action :verify_confirmed_email!
+ before_action :verify_confirmed_email!, :verify_admin_allowed!
layout 'profile'
@@ -97,4 +97,19 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
pre_auth.error = :unconfirmed_email
render "doorkeeper/authorizations/error"
end
+
+ def verify_admin_allowed!
+ render "doorkeeper/authorizations/forbidden" if disallow_connect?
+ end
+
+ def disallow_connect?
+ # we're disabling Cop/UserAdmin as OAuth tokens don't seem to respect admin mode
+ current_user&.admin? && Gitlab::CurrentSettings.disable_admin_oauth_scopes && dangerous_scopes? # rubocop:disable Cop/UserAdmin
+ 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?
+ end
end
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
index ead5d7c9026..1216353be36 100644
--- a/app/controllers/passwords_controller.rb
+++ b/app/controllers/passwords_controller.rb
@@ -2,6 +2,7 @@
class PasswordsController < Devise::PasswordsController
include GitlabRecaptcha
+ include Gitlab::Tracking::Helpers::WeakPasswordErrorEvent
skip_before_action :require_no_authentication, only: [:edit, :update]
@@ -41,6 +42,8 @@ class PasswordsController < Devise::PasswordsController
resource.password_automatically_set = false
resource.password_expires_at = nil
resource.save(validate: false) if resource.changed?
+ else
+ track_weak_password_error(@user, self.class.name, 'create')
end
end
end
diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb
index 5eb0f80ddc9..738c41207d5 100644
--- a/app/controllers/profiles/passwords_controller.rb
+++ b/app/controllers/profiles/passwords_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Profiles::PasswordsController < Profiles::ApplicationController
+ include Gitlab::Tracking::Helpers::WeakPasswordErrorEvent
+
skip_before_action :check_password_expiration, only: [:new, :create]
skip_before_action :check_two_factor_requirement, only: [:new, :create]
@@ -27,6 +29,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController
redirect_to root_path, notice: _('Password successfully changed')
else
+ track_weak_password_error(@user, self.class.name, 'create')
render :new
end
end
@@ -48,6 +51,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController
flash[:notice] = _('Password was successfully updated. Please sign in again.')
redirect_to new_user_session_path
else
+ track_weak_password_error(@user, self.class.name, 'update')
@user.reset
render 'edit'
end
@@ -94,3 +98,5 @@ class Profiles::PasswordsController < Profiles::ApplicationController
}
end
end
+
+Profiles::PasswordsController.prepend_mod
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index 4cf26d3e1e2..1663aa61f62 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
+ include RenderAccessTokens
+
feature_category :authentication_and_authorization
before_action :check_personal_access_tokens_enabled
@@ -16,7 +18,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
respond_to do |format|
format.html
format.json do
- render json: @active_personal_access_tokens
+ render json: @active_access_tokens
end
end
end
@@ -30,7 +32,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
if result.success?
render json: { new_token: @personal_access_token.token,
- active_access_tokens: active_personal_access_tokens }, status: :ok
+ active_access_tokens: active_access_tokens }, status: :ok
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
@@ -56,36 +58,13 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
def set_index_vars
@scopes = Gitlab::Auth.available_scopes_for(current_user)
- @active_personal_access_tokens = active_personal_access_tokens
+ @active_access_tokens = active_access_tokens
end
- def active_personal_access_tokens
- tokens = finder(state: 'active', sort: 'expires_at_asc_id_desc').execute
-
- if Feature.enabled?('access_token_pagination')
- tokens = tokens.page(page)
- add_pagination_headers(tokens)
- end
-
+ def represent(tokens)
::PersonalAccessTokenSerializer.new.represent(tokens)
end
- def add_pagination_headers(relation)
- Gitlab::Pagination::OffsetHeaderBuilder.new(
- request_context: self,
- per_page: relation.limit_value,
- page: relation.current_page,
- next_page: relation.next_page,
- prev_page: relation.prev_page,
- total: relation.total_count,
- params: params.permit(:page)
- ).execute
- end
-
- def page
- (params[:page] || 1).to_i
- end
-
def check_personal_access_tokens_enabled
render_404 if Gitlab::CurrentSettings.personal_access_tokens_disabled?
end
diff --git a/app/controllers/projects/alerting/notifications_controller.rb b/app/controllers/projects/alerting/notifications_controller.rb
index f3283c88740..89e8a261288 100644
--- a/app/controllers/projects/alerting/notifications_controller.rb
+++ b/app/controllers/projects/alerting/notifications_controller.rb
@@ -18,8 +18,9 @@ module Projects
def create
token = extract_alert_manager_token(request)
result = notify_service.execute(token, integration)
+ has_something_to_return = result.success? && result.http_status != :created
- if result.success?
+ if has_something_to_return
render json: AlertManagement::AlertSerializer.new.represent(result.payload[:alerts]), code: result.http_status
else
head result.http_status
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index 2256471047d..25b83aed78a 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -38,9 +38,9 @@ class Projects::ApplicationController < ApplicationController
if build.debug_mode?
access_denied!(
_('You must have developer or higher permissions in the associated project to view job logs when debug trace ' \
- "is enabled. To disable debug trace, set the 'CI_DEBUG_TRACE' variable to 'false' in your pipeline " \
- 'configuration or CI/CD settings. If you need to view this job log, a project maintainer or owner must add ' \
- 'you to the project with developer permissions or higher.')
+ "is enabled. To disable debug trace, set the 'CI_DEBUG_TRACE' and 'CI_DEBUG_SERVICES' variables to 'false' " \
+ 'in your pipeline configuration or CI/CD settings. If you must view this job log, a project maintainer ' \
+ 'or owner must add you to the project with developer permissions or higher.')
)
else
access_denied!(_('The current user is not authorized to access the job log.'))
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index 40e89a06b46..c3dcde38d09 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -4,6 +4,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
include ExtractsPath
include RendersBlob
include SendFileUpload
+ include Gitlab::Ci::Artifacts::Logger
urgency :low, [:browse, :file, :latest_succeeded]
@@ -26,12 +27,6 @@ class Projects::ArtifactsController < Projects::ApplicationController
# 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)
-
- finder = Ci::JobArtifactsFinder.new(@project, artifacts_params)
- all_artifacts = finder.execute
-
- @artifacts = all_artifacts.page(params[:page]).per(MAX_PER_PAGE)
- @total_size = all_artifacts.total_size
end
def destroy
@@ -47,6 +42,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
def download
return render_404 unless artifacts_file
+ log_artifacts_filesize(artifacts_file.model)
send_upload(artifacts_file, attachment: artifacts_file.filename, proxy: params[:proxy])
end
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index 6a6701ead15..84872d1e978 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -7,7 +7,7 @@ class Projects::BoardsController < Projects::ApplicationController
before_action :check_issues_available!
before_action do
push_frontend_feature_flag(:board_multi_select, project)
- push_frontend_feature_flag(:realtime_labels, project&.group)
+ push_frontend_feature_flag(:apollo_boards, project)
experiment(:prominent_create_board_btn, subject: current_user) do |e|
e.control {}
e.candidate {}
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index 2b2764d2e34..870320a79d9 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -86,7 +86,7 @@ class Projects::CommitController < Projects::ApplicationController
respond_to do |format|
format.json do
- render json: @merge_requests.to_json
+ render json: Gitlab::Json.dump(@merge_requests)
end
end
end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 4f037cc843e..67f2f85ce65 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -183,7 +183,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def metrics
respond_to do |format|
format.html do
- redirect_to project_metrics_dashboard_path(project, environment: environment )
+ redirect_to project_metrics_dashboard_path(project, environment: environment)
end
format.json do
# Currently, this acts as a hint to load the metrics details into the cache
diff --git a/app/controllers/projects/google_cloud/configuration_controller.rb b/app/controllers/projects/google_cloud/configuration_controller.rb
index 06a6674d578..e109ab95d39 100644
--- a/app/controllers/projects/google_cloud/configuration_controller.rb
+++ b/app/controllers/projects/google_cloud/configuration_controller.rb
@@ -15,7 +15,7 @@ module Projects
gcpRegions: gcp_regions,
revokeOauthUrl: revoke_oauth_url
}
- @js_data = js_data.to_json
+ @js_data = Gitlab::Json.dump(js_data)
track_event(:render_page)
end
diff --git a/app/controllers/projects/google_cloud/databases_controller.rb b/app/controllers/projects/google_cloud/databases_controller.rb
index 77ee830fd24..b511a85b0b8 100644
--- a/app/controllers/projects/google_cloud/databases_controller.rb
+++ b/app/controllers/projects/google_cloud/databases_controller.rb
@@ -17,7 +17,8 @@ module Projects
cloudsqlInstances: ::GoogleCloud::GetCloudsqlInstancesService.new(project).execute,
emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg')
}
- @js_data = js_data.to_json
+
+ @js_data = Gitlab::Json.dump(js_data)
track_event(:render_page)
end
@@ -27,7 +28,7 @@ module Projects
@title = title(product)
- @js_data = {
+ js_data = {
gcpProjects: gcp_projects,
refs: refs,
cancelPath: project_google_cloud_databases_path(project),
@@ -35,7 +36,9 @@ module Projects
formDescription: description(product),
databaseVersions: Projects::GoogleCloud::CloudsqlHelper::VERSIONS[product],
tiers: Projects::GoogleCloud::CloudsqlHelper::TIERS
- }.to_json
+ }
+
+ @js_data = Gitlab::Json.dump(js_data)
track_event(:render_form)
render template: 'projects/google_cloud/databases/cloudsql_form', formats: :html
diff --git a/app/controllers/projects/google_cloud/deployments_controller.rb b/app/controllers/projects/google_cloud/deployments_controller.rb
index f6cc8d5eafb..041486eb2fb 100644
--- a/app/controllers/projects/google_cloud/deployments_controller.rb
+++ b/app/controllers/projects/google_cloud/deployments_controller.rb
@@ -11,7 +11,7 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base
enableCloudRunUrl: project_google_cloud_deployments_cloud_run_path(project),
enableCloudStorageUrl: project_google_cloud_deployments_cloud_storage_path(project)
}
- @js_data = js_data.to_json
+ @js_data = Gitlab::Json.dump(js_data)
track_event(:render_page)
end
diff --git a/app/controllers/projects/google_cloud/gcp_regions_controller.rb b/app/controllers/projects/google_cloud/gcp_regions_controller.rb
index 2f0bc05030f..c51261721b2 100644
--- a/app/controllers/projects/google_cloud/gcp_regions_controller.rb
+++ b/app/controllers/projects/google_cloud/gcp_regions_controller.rb
@@ -14,7 +14,7 @@ class Projects::GoogleCloud::GcpRegionsController < Projects::GoogleCloud::BaseC
refs: refs,
cancelPath: project_google_cloud_configuration_path(project)
}
- @js_data = js_data.to_json
+ @js_data = Gitlab::Json.dump(js_data)
track_event(:render_form)
end
diff --git a/app/controllers/projects/google_cloud/service_accounts_controller.rb b/app/controllers/projects/google_cloud/service_accounts_controller.rb
index 89d624764df..7b029e25ea2 100644
--- a/app/controllers/projects/google_cloud/service_accounts_controller.rb
+++ b/app/controllers/projects/google_cloud/service_accounts_controller.rb
@@ -14,7 +14,7 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::
refs: refs,
cancelPath: project_google_cloud_configuration_path(project)
}
- @js_data = js_data.to_json
+ @js_data = Gitlab::Json.dump(js_data)
track_event(:render_form)
end
diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb
index 47557133ac8..6da70b5e157 100644
--- a/app/controllers/projects/graphs_controller.rb
+++ b/app/controllers/projects/graphs_controller.rb
@@ -104,7 +104,7 @@ class Projects::GraphsController < Projects::ApplicationController
}
end
- render json: @log.to_json
+ render json: Gitlab::Json.dump(@log)
end
def tracking_namespace_source
diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb
index 089ee860ea6..599505dcb6d 100644
--- a/app/controllers/projects/incidents_controller.rb
+++ b/app/controllers/projects/incidents_controller.rb
@@ -9,7 +9,6 @@ class Projects::IncidentsController < Projects::ApplicationController
before_action do
push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?)
- push_frontend_feature_flag(:work_items_hierarchy, @project)
end
feature_category :incident_management
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 5b1117c0224..ee845cd001e 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class Projects::IssuesController < Projects::ApplicationController
- include RendersNotes
include ToggleSubscriptionAction
include IssuableActions
include ToggleAwardEmoji
@@ -49,11 +48,14 @@ class Projects::IssuesController < Projects::ApplicationController
push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?)
end
+ before_action only: :index do
+ push_frontend_feature_flag(:or_issuable_queries, project)
+ end
+
before_action only: :show do
push_frontend_feature_flag(:issue_assignees_widget, project)
- push_frontend_feature_flag(:realtime_labels, project)
+ push_frontend_feature_flag(:work_items_mvc, project&.group)
push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?)
- push_frontend_feature_flag(:work_items_hierarchy, project)
push_frontend_feature_flag(:epic_widget_edit_confirmation, project)
push_force_frontend_feature_flag(:work_items_create_from_markdown, project&.work_items_create_from_markdown_feature_flag_enabled?)
end
@@ -405,7 +407,6 @@ class Projects::IssuesController < Projects::ApplicationController
options = super
options[:issue_types] = Issue::TYPES_FOR_LIST
- options[:issue_types] = options[:issue_types].excluding('task') unless project.work_items_feature_flag_enabled?
if service_desk?
options.reject! { |key| key == 'author_username' || key == 'author_id' }
@@ -432,10 +433,13 @@ class Projects::IssuesController < Projects::ApplicationController
def create_vulnerability_issue_feedback(issue); end
def redirect_if_task
- return render_404 if issue.task? && !project.work_items_feature_flag_enabled?
return unless issue.task?
- redirect_to project_work_items_path(project, issue.id, params: request.query_parameters)
+ 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
end
end
diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb
index 8ec2cbb41e9..14f2e372bc5 100644
--- a/app/controllers/projects/labels_controller.rb
+++ b/app/controllers/projects/labels_controller.rb
@@ -68,9 +68,10 @@ class Projects::LabelsController < Projects::ApplicationController
def generate
Gitlab::IssuesLabels.generate(@project)
- if params[:redirect] == 'issues'
+ case params[:redirect]
+ when 'issues'
redirect_to project_issues_path(@project)
- elsif params[:redirect] == 'merge_requests'
+ when 'merge_requests'
redirect_to project_merge_requests_path(@project)
else
redirect_to project_labels_path(@project)
diff --git a/app/controllers/projects/learn_gitlab_controller.rb b/app/controllers/projects/learn_gitlab_controller.rb
index 61e4a1812ba..6fe009c8a28 100644
--- a/app/controllers/projects/learn_gitlab_controller.rb
+++ b/app/controllers/projects/learn_gitlab_controller.rb
@@ -23,7 +23,6 @@ class Projects::LearnGitlabController < Projects::ApplicationController
experiment(:invite_for_help_continuous_onboarding, namespace: project.namespace) do |e|
e.candidate {}
- e.publish_to_database
end
end
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 418e7233e21..c88dbc70ed5 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -38,16 +38,13 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
diffs = @compare.diffs_in_batch(params[:page], params[:per_page], diff_options: diff_options_hash)
unfoldable_positions = @merge_request.note_positions_for_paths(diffs.diff_file_paths, current_user).unfoldable
- diffs.unfold_diff_files(unfoldable_positions)
- diffs.write_cache
-
options = {
merge_request: @merge_request,
commit: commit,
diff_view: diff_view,
merge_ref_head_diff: render_merge_ref_head_diff?,
pagination_data: diffs.pagination_data,
- allow_tree_conflicts: display_merge_conflicts_in_diff?
+ merge_conflicts_in_diff: display_merge_conflicts_in_diff?
}
# NOTE: Any variables that would affect the resulting json needs to be added to the cache_context to avoid stale cache issues.
@@ -60,10 +57,19 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
params[:page],
params[:per_page],
options[:merge_ref_head_diff],
- options[:allow_tree_conflicts]
+ options[:merge_conflicts_in_diff]
]
- return unless stale?(etag: [cache_context + diff_options_hash.fetch(:paths, []), diffs])
+ if Feature.enabled?(:check_etags_diffs_batch_before_write_cache, merge_request.project) && !stale?(etag: [cache_context + diff_options_hash.fetch(:paths, []), diffs])
+ return
+ end
+
+ diffs.unfold_diff_files(unfoldable_positions)
+ diffs.write_cache
+
+ if Feature.disabled?(:check_etags_diffs_batch_before_write_cache, merge_request.project) && !stale?(etag: [cache_context + diff_options_hash.fetch(:paths, []), diffs])
+ return
+ end
render json: PaginatedDiffSerializer.new(current_user: current_user).represent(diffs, options)
end
@@ -75,7 +81,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
options = additional_attributes.merge(
only_context_commits: show_only_context_commits?,
merge_ref_head_diff: render_merge_ref_head_diff?,
- allow_tree_conflicts: display_merge_conflicts_in_diff?
+ merge_conflicts_in_diff: display_merge_conflicts_in_diff?
)
render json: DiffsMetadataSerializer.new(project: @merge_request.project, current_user: current_user)
@@ -104,7 +110,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
options = additional_attributes.merge(
diff_view: "inline",
merge_ref_head_diff: render_merge_ref_head_diff?,
- allow_tree_conflicts: display_merge_conflicts_in_diff?
+ merge_conflicts_in_diff: display_merge_conflicts_in_diff?
)
options[:context_commits] = @merge_request.recent_context_commits
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 9c139733248..4ba79d43f27 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -3,7 +3,6 @@
class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationController
include ToggleSubscriptionAction
include IssuableActions
- include RendersNotes
include RendersCommits
include RendersAssignees
include ToggleAwardEmoji
@@ -32,18 +31,15 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
before_action only: [:show] do
- push_frontend_feature_flag(:merge_request_widget_graphql, project)
push_frontend_feature_flag(:core_security_mr_widget_counts, project)
- push_frontend_feature_flag(:refactor_code_quality_extension, project)
- push_frontend_feature_flag(:refactor_mr_widget_test_summary, project)
push_frontend_feature_flag(:issue_assignees_widget, @project)
- push_frontend_feature_flag(:realtime_labels, project)
push_frontend_feature_flag(:refactor_security_extension, @project)
push_frontend_feature_flag(:refactor_code_quality_inline_findings, project)
push_frontend_feature_flag(:moved_mr_sidebar, project)
push_frontend_feature_flag(:paginated_mr_discussions, project)
push_frontend_feature_flag(:mr_review_submit_comment, project)
push_frontend_feature_flag(:mr_experience_survey, project)
+ push_frontend_feature_flag(:realtime_reviewers, project)
end
before_action do
@@ -123,12 +119,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@commits_count = @merge_request.commits_count + @merge_request.context_commits_count
@diffs_count = get_diffs_count
@issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar')
- @current_user_data = UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestCurrentUserEntity).to_json
+ @current_user_data = Gitlab::Json.dump(UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestCurrentUserEntity))
@show_whitespace_default = current_user.nil? || current_user.show_whitespace_in_diffs
@file_by_file_default = current_user&.view_diffs_file_by_file
@coverage_path = coverage_reports_project_merge_request_path(@project, @merge_request, format: :json) if @merge_request.has_coverage_reports?
@update_current_user_path = expose_path(api_v4_user_preferences_path)
@endpoint_metadata_url = endpoint_metadata_url(@project, @merge_request)
+ @endpoint_diff_batch_url = endpoint_diff_batch_url(@project, @merge_request)
set_pipeline_variables
@@ -179,15 +176,15 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@merge_request.recent_context_commits
)
- # Get commits from repository
- # or from cache if already merged
- @commits =
- set_commits_for_rendering(
- @merge_request.recent_commits(load_from_gitaly: true).with_latest_pipeline(@merge_request.source_branch).with_markdown_cache,
- commits_count: @merge_request.commits_count
- )
+ per_page = [(params[:per_page] || MergeRequestDiff::COMMITS_SAFE_SIZE).to_i, MergeRequestDiff::COMMITS_SAFE_SIZE].min
+ recent_commits = @merge_request.recent_commits(load_from_gitaly: true, limit: per_page, page: params[:page]).with_latest_pipeline(@merge_request.source_branch).with_markdown_cache
+ @next_page = recent_commits.next_page
+ @commits = set_commits_for_rendering(
+ recent_commits,
+ commits_count: @merge_request.commits_count
+ )
- render json: { html: view_to_html_string('projects/merge_requests/_commits') }
+ render json: { html: view_to_html_string('projects/merge_requests/_commits'), next_page: @next_page }
end
def pipelines
@@ -535,7 +532,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
render json: '', status: :no_content
when :parsed
- render json: report_comparison[:data].to_json, status: :ok
+ render json: Gitlab::Json.dump(report_comparison[:data]), status: :ok
when :error
render json: { status_reason: report_comparison[:status_reason] }, status: :bad_request
else
@@ -553,12 +550,23 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
return render_404 unless can?(current_user, :read_build, merge_request.actual_head_pipeline)
end
+ def show_whitespace
+ current_user&.show_whitespace_in_diffs ? '0' : '1'
+ end
+
def endpoint_metadata_url(project, merge_request)
- params = request.query_parameters.merge(view: 'inline', diff_head: true, w: current_user&.show_whitespace_in_diffs ? '0' : '1')
+ params = request.query_parameters.merge(view: 'inline', diff_head: true, w: show_whitespace)
diffs_metadata_project_json_merge_request_path(project, merge_request, 'json', params)
end
+ def endpoint_diff_batch_url(project, merge_request)
+ per_page = current_user&.view_diffs_file_by_file ? '1' : '5'
+ params = request.query_parameters.merge(view: 'inline', diff_head: true, w: show_whitespace, page: '0', per_page: per_page)
+
+ diffs_batch_project_json_merge_request_path(project, merge_request, 'json', params)
+ end
+
def convert_date_to_epoch(date)
Date.strptime(date, "%Y-%m-%d")&.to_time&.to_i if date
rescue Date::Error, TypeError
diff --git a/app/controllers/projects/ml/experiments_controller.rb b/app/controllers/projects/ml/experiments_controller.rb
new file mode 100644
index 00000000000..749586791ac
--- /dev/null
+++ b/app/controllers/projects/ml/experiments_controller.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Projects
+ module Ml
+ class ExperimentsController < ::Projects::ApplicationController
+ include Projects::Ml::ExperimentsHelper
+ before_action :check_feature_flag
+
+ feature_category :mlops
+
+ MAX_PER_PAGE = 20
+
+ def index
+ @experiments = ::Ml::Experiment.by_project_id(@project.id).page(params[:page]).per(MAX_PER_PAGE)
+ 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?
+
+ @candidates = @experiment.candidates&.including_metrics_and_params
+ end
+
+ private
+
+ def check_feature_flag
+ render_404 unless Feature.enabled?(:ml_experiment_tracking, @project)
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index d24b232293b..9d3506d49b0 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -58,7 +58,7 @@ class Projects::NotesController < Projects::ApplicationController
def outdated_line_change
diff_lines = Rails.cache.fetch(['note', note.id, 'oudated_line_change'], expires_in: 7.days) do
- ::MergeRequests::OutdatedDiscussionDiffLinesService.new(project: note.noteable.source_project, note: note).execute.to_json
+ Gitlab::Json.dump(::MergeRequests::OutdatedDiscussionDiffLinesService.new(project: note.noteable.source_project, note: note).execute)
end
render json: diff_lines
diff --git a/app/controllers/projects/packages/infrastructure_registry_controller.rb b/app/controllers/projects/packages/infrastructure_registry_controller.rb
index f1410bf6043..733df9fdb45 100644
--- a/app/controllers/projects/packages/infrastructure_registry_controller.rb
+++ b/app/controllers/projects/packages/infrastructure_registry_controller.rb
@@ -5,7 +5,7 @@ module Projects
class InfrastructureRegistryController < Projects::ApplicationController
include PackagesAccess
- feature_category :infrastructure_as_code
+ feature_category :package_registry
urgency :low
def show
diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
index ca787785901..31030d958df 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -98,7 +98,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
def schedule_params
params.require(:schedule)
.permit(:description, :cron, :cron_timezone, :ref, :active,
- variables_attributes: [:id, :variable_type, :key, :secret_value, :_destroy] )
+ variables_attributes: [:id, :variable_type, :key, :secret_value, :_destroy])
end
def authorize_play_pipeline_schedule!
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 01f7bb9e2cf..7d1a75ae449 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -140,21 +140,13 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def builds
- if Feature.enabled?(:pipeline_tabs_vue, project)
- redirect_to pipeline_path(@pipeline, tab: 'builds')
- else
- render_show
- end
+ render_show
end
def dag
respond_to do |format|
format.html do
- if Feature.enabled?(:pipeline_tabs_vue, project)
- redirect_to pipeline_path(@pipeline, tab: 'dag')
- else
- render_show
- end
+ render_show
end
format.json do
render json: Ci::DagPipelineSerializer
@@ -165,9 +157,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def failures
- if Feature.enabled?(:pipeline_tabs_vue, project)
- redirect_to pipeline_path(@pipeline, tab: 'failures')
- elsif @pipeline.failed_builds.present?
+ if @pipeline.failed_builds.present?
render_show
else
redirect_to pipeline_path(@pipeline)
@@ -222,11 +212,7 @@ class Projects::PipelinesController < Projects::ApplicationController
def test_report
respond_to do |format|
format.html do
- if Feature.enabled?(:pipeline_tabs_vue, project)
- redirect_to pipeline_path(@pipeline, tab: 'test_report')
- else
- render_show
- end
+ render_show
end
format.json do
render json: TestReportSerializer
@@ -352,7 +338,6 @@ class Projects::PipelinesController < Projects::ApplicationController
experiment(:runners_availability_section, namespace: project.root_ancestor) do |e|
e.candidate {}
- e.publish_to_database
end
end
diff --git a/app/controllers/projects/product_analytics_controller.rb b/app/controllers/projects/product_analytics_controller.rb
deleted file mode 100644
index 8085b0a6334..00000000000
--- a/app/controllers/projects/product_analytics_controller.rb
+++ /dev/null
@@ -1,61 +0,0 @@
-# frozen_string_literal: true
-
-class Projects::ProductAnalyticsController < Projects::ApplicationController
- before_action :feature_enabled!, only: [:index, :setup, :test, :graphs]
- before_action :authorize_read_product_analytics!
- before_action :tracker_variables, only: [:setup, :test]
-
- feature_category :product_analytics
-
- def index
- @events = product_analytics_events.order_by_time.page(params[:page])
- end
-
- def setup
- end
-
- def test
- @event = product_analytics_events.try(:first)
- end
-
- def graphs
- @graphs = []
- @timerange = 30
-
- requested_graphs = %w(platform os_timezone br_lang doc_charset)
-
- requested_graphs.each do |graph|
- @graphs << ProductAnalytics::BuildGraphService
- .new(project, { graph: graph, timerange: @timerange })
- .execute
- end
-
- @activity_graph = ProductAnalytics::BuildActivityGraphService
- .new(project, { timerange: @timerange })
- .execute
- end
-
- private
-
- def product_analytics_events
- @project.product_analytics_events
- end
-
- def tracker_variables
- # We use project id as Snowplow appId
- @project_id = @project.id.to_s
-
- # Snowplow remembers values like appId and platform between reloads.
- # That is why we have to rename the tracker with a random integer.
- @random = rand(999999)
-
- # Generate random platform every time a tracker is rendered.
- @platform = %w(web mob app)[(@random % 3)]
- end
-
- def feature_enabled!
- render_404 unless Feature.enabled?(:product_analytics, @project)
- end
-end
-
-Projects::ProductAnalyticsController.prepend_mod_with('Projects::ProductAnalyticsController')
diff --git a/app/controllers/projects/prometheus/alerts_controller.rb b/app/controllers/projects/prometheus/alerts_controller.rb
index c3dc17694d9..27ac64e5758 100644
--- a/app/controllers/projects/prometheus/alerts_controller.rb
+++ b/app/controllers/projects/prometheus/alerts_controller.rb
@@ -23,11 +23,7 @@ module Projects
token = extract_alert_manager_token(request)
result = notify_service.execute(token)
- if result.success?
- render json: AlertManagement::AlertSerializer.new.represent(result.payload[:alerts]), code: result.http_status
- else
- head result.http_status
- end
+ head result.http_status
end
private
@@ -37,19 +33,6 @@ module Projects
.new(project, params.permit!)
end
- def serialize_as_json(alert_obj)
- serializer.represent(alert_obj)
- end
-
- def serializer
- PrometheusAlertSerializer
- .new(project: project, current_user: current_user)
- end
-
- def alerts
- alerts_finder.execute
- end
-
def alert
@alert ||= alerts_finder(metric: params[:id]).execute.first || render_404
end
diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb
index 87cb8e4781f..ffe95bf4fee 100644
--- a/app/controllers/projects/registry/repositories_controller.rb
+++ b/app/controllers/projects/registry/repositories_controller.rb
@@ -8,10 +8,6 @@ module Projects
before_action :authorize_update_container_image!, only: [:destroy]
- before_action do
- push_frontend_feature_flag(:container_registry_show_shortened_path, project)
- end
-
def index
respond_to do |format|
format.html { ensure_root_container_repository! }
@@ -26,7 +22,11 @@ module Projects
def destroy
image.delete_scheduled!
- DeleteContainerRepositoryWorker.perform_async(current_user.id, image.id) # rubocop:disable CodeReuse/Worker
+
+ unless Feature.enabled?(:container_registry_delete_repository_with_cron_worker)
+ DeleteContainerRepositoryWorker.perform_async(current_user.id, image.id) # rubocop:disable CodeReuse/Worker
+ end
+
track_package_event(:delete_repository, :container)
respond_to do |format|
diff --git a/app/controllers/projects/settings/access_tokens_controller.rb b/app/controllers/projects/settings/access_tokens_controller.rb
index bac35583a97..0884816ef62 100644
--- a/app/controllers/projects/settings/access_tokens_controller.rb
+++ b/app/controllers/projects/settings/access_tokens_controller.rb
@@ -3,6 +3,7 @@
module Projects
module Settings
class AccessTokensController < Projects::ApplicationController
+ include RenderAccessTokens
include AccessTokensActions
layout 'project_settings'
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index cda6c8abea7..8aef1c3d24d 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -11,10 +11,6 @@ module Projects
before_action :authorize_admin_pipeline!
before_action :check_builds_available!
before_action :define_variables
- before_action do
- push_frontend_feature_flag(:ajax_new_deploy_token, @project)
- push_frontend_feature_flag(:ci_variable_settings_graphql, @project)
- end
helper_method :highlight_badge
@@ -23,9 +19,11 @@ module Projects
def show
if Feature.enabled?(:ci_pipeline_triggers_settings_vue_ui, @project)
- @triggers_json = ::Ci::TriggerSerializer.new.represent(
+ triggers = ::Ci::TriggerSerializer.new.represent(
@project.triggers, current_user: current_user, project: @project
- ).to_json
+ )
+
+ @triggers_json = Gitlab::Json.dump(triggers)
end
render
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index 43c6451577a..90988645d3a 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -6,11 +6,8 @@ module Projects
layout 'project_settings'
before_action :authorize_admin_project!
before_action :define_variables, only: [:create_deploy_token]
- before_action do
- push_frontend_feature_flag(:ajax_new_deploy_token, @project)
- end
- feature_category :source_code_management, [:show, :cleanup]
+ feature_category :source_code_management, [:show, :cleanup, :update]
feature_category :continuous_delivery, [:create_deploy_token]
urgency :low, [:show, :create_deploy_token]
@@ -60,6 +57,19 @@ module Projects
end
end
+ def update
+ result = ::Projects::UpdateService.new(@project, current_user, project_params).execute
+
+ if result[:status] == :success
+ flash[:notice] = _("Project settings were successfully updated.")
+ else
+ flash[:alert] = result[:message]
+ @project.reset
+ end
+
+ redirect_to project_settings_repository_path(project)
+ end
+
private
def render_show
@@ -97,6 +107,18 @@ module Projects
params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry, :write_registry, :read_package_registry, :write_package_registry, :username)
end
+ def project_params
+ params.require(:project).permit(project_params_attributes)
+ end
+
+ def project_params_attributes
+ [
+ :issue_branch_template,
+ :default_branch,
+ :autoclose_referenced_issues
+ ]
+ end
+
def access_levels_options
{
create_access_levels: levels_for_dropdown,
diff --git a/app/controllers/projects/starrers_controller.rb b/app/controllers/projects/starrers_controller.rb
index bc857648a06..06996e8e5fc 100644
--- a/app/controllers/projects/starrers_controller.rb
+++ b/app/controllers/projects/starrers_controller.rb
@@ -11,14 +11,8 @@ class Projects::StarrersController < Projects::ApplicationController
@starrers = UsersStarProjectsFinder.new(@project, params, current_user: @current_user).execute
@sort = params[:sort].presence || sort_value_name
@starrers = @starrers.preload_users.sort_by_attribute(@sort).page(params[:page])
- @public_count = @project.starrers.with_public_profile.size
- @total_count = @project.starrers.size
+ @public_count = @project.starrers.active.with_public_profile.size
+ @total_count = @project.starrers.active.size
@private_count = @total_count - @public_count
end
-
- private
-
- def has_starred_project?(starrers)
- starrers.first { |starrer| starrer.user_id == current_user.id }
- end
end
diff --git a/app/controllers/projects/templates_controller.rb b/app/controllers/projects/templates_controller.rb
index 6d06b05c1e9..ed5fb838670 100644
--- a/app/controllers/projects/templates_controller.rb
+++ b/app/controllers/projects/templates_controller.rb
@@ -12,7 +12,7 @@ class Projects::TemplatesController < Projects::ApplicationController
templates = @template_type.template_subsets(project)
respond_to do |format|
- format.json { render json: templates.to_json }
+ format.json { render json: Gitlab::Json.dump(templates) }
end
end
@@ -20,7 +20,7 @@ class Projects::TemplatesController < Projects::ApplicationController
template = @template_type.find(params[:key], project)
respond_to do |format|
- format.json { render json: template.to_json }
+ format.json { render json: Gitlab::Json.dump(template) }
end
end
diff --git a/app/controllers/projects/usage_quotas_controller.rb b/app/controllers/projects/usage_quotas_controller.rb
index 07a3c010f4f..d3757eaf481 100644
--- a/app/controllers/projects/usage_quotas_controller.rb
+++ b/app/controllers/projects/usage_quotas_controller.rb
@@ -5,7 +5,7 @@ class Projects::UsageQuotasController < Projects::ApplicationController
layout "project_settings"
- feature_category :utilization
+ feature_category :subscription_cost_management
urgency :low
def index
diff --git a/app/controllers/projects/work_items_controller.rb b/app/controllers/projects/work_items_controller.rb
index b794785f285..a7e59a28fb7 100644
--- a/app/controllers/projects/work_items_controller.rb
+++ b/app/controllers/projects/work_items_controller.rb
@@ -4,13 +4,9 @@ class Projects::WorkItemsController < Projects::ApplicationController
before_action do
push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?)
- push_frontend_feature_flag(:work_items_hierarchy, project)
+ push_frontend_feature_flag(:use_iid_in_work_items_path, project)
end
feature_category :team_planning
urgency :low
-
- def index
- render_404 unless project&.work_items_feature_flag_enabled?
- end
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index b7b6e6534fb..a5dacbf7f2f 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -46,7 +46,6 @@ class ProjectsController < Projects::ApplicationController
push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?)
push_frontend_feature_flag(:package_registry_access_level)
- push_frontend_feature_flag(:work_items_hierarchy, @project)
end
before_action only: :edit do
@@ -343,7 +342,7 @@ class ProjectsController < Projects::ApplicationController
options['Commits'] = [ref]
end
- render json: options.to_json
+ render json: Gitlab::Json.dump(options)
rescue Gitlab::Git::CommandError
render json: { error: _('Unable to load refs') }, status: :service_unavailable
end
@@ -440,7 +439,7 @@ class ProjectsController < Projects::ApplicationController
def operations_feature_attributes
if Feature.enabled?(:split_operations_visibility_permissions, project)
%i[
- environments_access_level feature_flags_access_level monitor_access_level
+ environments_access_level feature_flags_access_level monitor_access_level infrastructure_access_level
]
else
%i[operations_access_level]
@@ -465,7 +464,6 @@ class ProjectsController < Projects::ApplicationController
:build_timeout_human_readable,
:resolve_outdated_diff_discussions,
:container_registry_enabled,
- :default_branch,
:description,
:emails_disabled,
:external_authorization_classification_label,
@@ -491,7 +489,6 @@ class ProjectsController < Projects::ApplicationController
:merge_method,
:initialize_with_sast,
:initialize_with_readme,
- :autoclose_referenced_issues,
:ci_separated_caches,
:suggestion_commit_message,
:packages_enabled,
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 31fe30f3f06..995303a631a 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -8,12 +8,15 @@ class RegistrationsController < Devise::RegistrationsController
include OneTrustCSP
include BizibleCSP
include GoogleAnalyticsCSP
+ include PreferredLanguageSwitcher
include RegistrationsTracking
+ include Gitlab::Tracking::Helpers::WeakPasswordErrorEvent
layout 'devise'
prepend_before_action :check_captcha, only: :create
before_action :ensure_destroy_prerequisites_met, only: [:destroy]
+ before_action :init_preferred_language, only: :new
before_action :load_recaptcha, only: :new
before_action :set_invite_params, only: :new
before_action only: [:create] do
@@ -33,15 +36,15 @@ class RegistrationsController < Devise::RegistrationsController
def create
set_user_state
- token = set_custom_confirmation_token
+ set_custom_confirmation_token
super do |new_user|
accept_pending_invitations if new_user.persisted?
persist_accepted_terms_if_required(new_user)
set_role_required(new_user)
- track_experiment_event(new_user)
- send_custom_confirmation_instructions(new_user, token)
+ send_custom_confirmation_instructions
+ track_weak_password_error(new_user, self.class.name, 'create')
if pending_approval?
NotificationService.new.new_instance_access_request(new_user)
@@ -127,7 +130,7 @@ class RegistrationsController < Devise::RegistrationsController
# after user confirms and comes back, he will be redirected
store_location_for(:redirect, users_sign_up_welcome_path(glm_tracking_params))
- return identity_verification_redirect_path if custom_confirmation_enabled?(resource)
+ return identity_verification_redirect_path if custom_confirmation_enabled?
users_almost_there_path(email: resource.email)
end
@@ -189,7 +192,8 @@ 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: registered_with_invite_email?,
+ preferred_language: preferred_language }))
.execute
end
@@ -239,19 +243,11 @@ class RegistrationsController < Devise::RegistrationsController
current_user
end
- def track_experiment_event(new_user)
- # Track signed up event to relate it with click "Sign up" button events from
- # the experimental logged out header with marketing links. This allows us to
- # have a funnel of visitors clicking on the header and those visitors
- # signing up and becoming users
- experiment(:logged_out_marketing_header, actor: new_user).track(:signed_up) if new_user.persisted?
- end
-
def identity_verification_redirect_path
# overridden by EE module
end
- def custom_confirmation_enabled?(resource)
+ def custom_confirmation_enabled?
# overridden by EE module
end
@@ -259,7 +255,7 @@ class RegistrationsController < Devise::RegistrationsController
# overridden by EE module
end
- def send_custom_confirmation_instructions(user, token)
+ def send_custom_confirmation_instructions
# overridden by EE module
end
end
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 7d4dd04c6d4..5351e3e9e77 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -9,7 +9,11 @@ class SearchController < ApplicationController
RESCUE_FROM_TIMEOUT_ACTIONS = [:count, :show, :autocomplete, :aggregations].freeze
- track_event :show, name: 'i_search_total', destinations: [:redis_hll, :snowplow]
+ track_custom_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]
@@ -108,7 +112,7 @@ class SearchController < ApplicationController
@ref = params[:project_ref] if params[:project_ref].present?
@filter = params[:filter]
- render json: search_autocomplete_opts(term, filter: @filter).to_json
+ render json: Gitlab::Json.dump(search_autocomplete_opts(term, filter: @filter))
end
def opensearch
@@ -140,8 +144,7 @@ class SearchController < ApplicationController
def check_single_commit_result?
return false if params[:force_search_results]
return false unless @project.present?
- # download_code project policy grants user the read_commit ability
- return false unless Ability.allowed?(current_user, :download_code, @project)
+ return false unless Ability.allowed?(current_user, :read_code, @project)
query = params[:search].strip.downcase
return false unless Commit.valid_hash?(query)
@@ -243,6 +246,10 @@ class SearchController < ApplicationController
search_service.project&.namespace || search_service.group
end
+ def tracking_project_source
+ search_service.project
+ end
+
def search_type
'basic'
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 5c969c437f4..c20a9aa4485 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -13,6 +13,7 @@ class SessionsController < Devise::SessionsController
include BizibleCSP
include VerifiesWithEmail
include GoogleAnalyticsCSP
+ include PreferredLanguageSwitcher
skip_before_action :check_two_factor_requirement, only: [:destroy]
skip_before_action :check_password_expiration, only: [:destroy]
@@ -30,6 +31,7 @@ class SessionsController < Devise::SessionsController
prepend_before_action :ensure_password_authentication_enabled!, if: -> { action_name == 'create' && password_based_login? }
before_action :auto_sign_in_with_provider, only: [:new]
+ before_action :init_preferred_language, only: :new
before_action :store_unauthenticated_sessions, only: [:new]
before_action :save_failed_login, if: :action_new_and_failed_login?
before_action :load_recaptcha
diff --git a/app/controllers/terraform/services_controller.rb b/app/controllers/terraform/services_controller.rb
index e7b9a94fd8e..7ebe1d9ba98 100644
--- a/app/controllers/terraform/services_controller.rb
+++ b/app/controllers/terraform/services_controller.rb
@@ -3,7 +3,7 @@
class Terraform::ServicesController < ApplicationController
skip_before_action :authenticate_user!
- feature_category :infrastructure_as_code
+ feature_category :package_registry
def index
render json: { 'modules.v1' => "/api/#{::API::API.version}/packages/terraform/modules/v1/" }
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index c35aa8e4346..0f03333d793 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -274,8 +274,6 @@ class UsersController < ApplicationController
def finder_params
{
- # don't display projects pending deletion
- without_deleted: true,
# don't display projects marked for deletion
not_aimed_for_deletion: true
}
diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb
index 863a2c41d4c..60bf47c2f12 100644
--- a/app/experiments/application_experiment.rb
+++ b/app/experiments/application_experiment.rb
@@ -3,25 +3,6 @@
class ApplicationExperiment < Gitlab::Experiment
control { nil } # provide a default control for anonymous experiments
- # Documented in:
- # https://gitlab.com/gitlab-org/gitlab/-/issues/357904
- # https://gitlab.com/gitlab-org/gitlab/-/issues/345932
- #
- # @deprecated
- def publish_to_database
- ActiveSupport::Deprecation.warn('publish_to_database is deprecated and should not be used for reporting anymore')
-
- return unless should_track?
-
- # if the context contains a namespace, group, project, user, or actor
- value = context.value
- subject = value[:namespace] || value[:group] || value[:project] || value[:user] || value[:actor]
- return unless ExperimentSubject.valid_subject?(subject)
-
- variant_name = :experimental if variant&.name != 'control'
- Experiment.add_subject(name, variant: variant_name || :control, subject: subject)
- end
-
def control_behavior
# define a default nil control behavior so we can omit it when not needed
end
diff --git a/app/experiments/require_verification_for_namespace_creation_experiment.rb b/app/experiments/require_verification_for_namespace_creation_experiment.rb
index cb667c6ae60..914c5c4a29e 100644
--- a/app/experiments/require_verification_for_namespace_creation_experiment.rb
+++ b/app/experiments/require_verification_for_namespace_creation_experiment.rb
@@ -12,18 +12,8 @@ class RequireVerificationForNamespaceCreationExperiment < ApplicationExperiment
run
end
- def record_conversion(namespace)
- return unless should_track?
-
- Experiment.by_name(name).record_conversion_event_for_subject(subject, namespace_id: namespace.id)
- end
-
private
- def subject
- context.value[:user]
- end
-
def existing_user
return false unless user_or_actor
diff --git a/app/experiments/security_reports_mr_widget_prompt_experiment.rb b/app/experiments/security_reports_mr_widget_prompt_experiment.rb
index 1bf3e15ba3b..0a5778950fa 100644
--- a/app/experiments/security_reports_mr_widget_prompt_experiment.rb
+++ b/app/experiments/security_reports_mr_widget_prompt_experiment.rb
@@ -3,10 +3,4 @@
class SecurityReportsMrWidgetPromptExperiment < ApplicationExperiment
control {}
candidate {}
-
- def publish(_result = nil)
- super
-
- publish_to_database
- end
end
diff --git a/app/finders/access_requests_finder.rb b/app/finders/access_requests_finder.rb
index 2cc8a978877..9b1407e2971 100644
--- a/app/finders/access_requests_finder.rb
+++ b/app/finders/access_requests_finder.rb
@@ -24,6 +24,6 @@ class AccessRequestsFinder
private
def can_see_access_requests?(current_user)
- source && Ability.allowed?(current_user, :"admin_#{source.class.to_s.underscore}", source)
+ source && Ability.allowed?(current_user, :admin_member_access_request, source)
end
end
diff --git a/app/finders/autocomplete/users_finder.rb b/app/finders/autocomplete/users_finder.rb
index 33d9a8a3dbc..bb91f84de99 100644
--- a/app/finders/autocomplete/users_finder.rb
+++ b/app/finders/autocomplete/users_finder.rb
@@ -12,7 +12,7 @@ module Autocomplete
attr_reader :current_user, :project, :group, :search, :skip_users,
:author_id, :todo_filter, :todo_state_filter,
- :filter_by_current_user
+ :filter_by_current_user, :states
def initialize(params:, current_user:, project:, group:)
@current_user = current_user
@@ -24,6 +24,7 @@ module Autocomplete
@todo_filter = params[:todo_filter]
@todo_state_filter = params[:todo_state_filter]
@filter_by_current_user = params[:current_user]
+ @states = params[:states] || ['active']
end
def execute
@@ -60,7 +61,8 @@ module Autocomplete
# reorder_by_name() is called _before_ optionally_search(), otherwise
# reorder_by_name will break the ORDER BY applied in optionally_search().
find_users
- .active
+ .where(state: states)
+ .non_internal
.reorder_by_name
.optionally_search(search, use_minimum_char_limit: use_minimum_char_limit)
.where_not_in(skip_users)
diff --git a/app/finders/clusters/agent_authorizations_finder.rb b/app/finders/clusters/agent_authorizations_finder.rb
index 8b939f5d646..70c0868cc7f 100644
--- a/app/finders/clusters/agent_authorizations_finder.rb
+++ b/app/finders/clusters/agent_authorizations_finder.rb
@@ -24,15 +24,7 @@ module Clusters
# rubocop: disable CodeReuse/ActiveRecord
def project_authorizations
- namespace_ids = if project.group
- if include_descendants?
- all_namespace_ids
- else
- ancestor_namespace_ids
- end
- else
- project.namespace_id
- end
+ namespace_ids = project.group ? all_namespace_ids : project.namespace_id
Clusters::Agents::ProjectAuthorization
.where(project_id: project.id)
@@ -57,35 +49,21 @@ module Clusters
authorizations[:group_id].eq(ordered_ancestors_cte.table[:id])
).join_sources
- authorized_groups = Clusters::Agents::GroupAuthorization
+ 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)
-
- authorized_groups = if include_descendants?
- authorized_groups.where(projects: { namespace_id: all_namespace_ids })
- else
- authorized_groups.where('projects.namespace_id IN (SELECT id FROM ordered_ancestors)')
- end
-
- authorized_groups.to_a
+ .to_a
end
# rubocop: enable CodeReuse/ActiveRecord
- def ancestor_namespace_ids
- project.ancestors.select(:id)
- end
-
def all_namespace_ids
project.root_ancestor.self_and_descendants.select(:id)
end
-
- def include_descendants?
- Feature.enabled?(:agent_authorization_include_descendants, project)
- end
end
end
diff --git a/app/finders/clusters/agent_tokens_finder.rb b/app/finders/clusters/agent_tokens_finder.rb
new file mode 100644
index 00000000000..e241836e1dc
--- /dev/null
+++ b/app/finders/clusters/agent_tokens_finder.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Clusters
+ class AgentTokensFinder
+ def initialize(object, current_user, agent_id)
+ @object = object
+ @current_user = current_user
+ @agent_id = agent_id
+ end
+
+ def execute
+ raise_not_found_unless_can_read_cluster
+
+ object.cluster_agents.find(agent_id).agent_tokens
+ end
+
+ private
+
+ attr_reader :object, :current_user, :agent_id
+
+ def raise_not_found_unless_can_read_cluster
+ raise ActiveRecord::RecordNotFound unless current_user&.can?(:read_cluster, object)
+ end
+ end
+end
diff --git a/app/finders/incident_management/timeline_event_tags_finder.rb b/app/finders/incident_management/timeline_event_tags_finder.rb
new file mode 100644
index 00000000000..71820bf7dcb
--- /dev/null
+++ b/app/finders/incident_management/timeline_event_tags_finder.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ class TimelineEventTagsFinder
+ def initialize(user, timeline_event, params = {})
+ @user = user
+ @timeline_event = timeline_event
+ @params = params
+ end
+
+ def execute
+ return ::IncidentManagement::TimelineEventTag.none unless allowed?
+
+ timeline_event.timeline_event_tags
+ end
+
+ private
+
+ attr_reader :user, :timeline_event, :params
+
+ def allowed?
+ Ability.allowed?(user, :read_incident_management_timeline_event_tag, timeline_event)
+ end
+ end
+end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 9f331d381aa..5fcb81949ee 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -46,7 +46,8 @@ class IssuableFinder
requires_cross_project_access unless: -> { params.project? }
- FULL_TEXT_SEARCH_TERM_REGEX = /\A[\p{ASCII}|\p{Latin}]+\z/.freeze
+ FULL_TEXT_SEARCH_TERM_PATTERN = '[\u0000-\u02FF\u1E00-\u1EFF\u2070-\u218F]*'
+ FULL_TEXT_SEARCH_TERM_REGEX = /\A#{FULL_TEXT_SEARCH_TERM_PATTERN}\z/.freeze
NEGATABLE_PARAMS_HELPER_KEYS = %i[project_id scope status include_subgroups].freeze
attr_accessor :current_user, :params
@@ -335,7 +336,7 @@ class IssuableFinder
return items if items.is_a?(ActiveRecord::NullRelation)
return items if Feature.enabled?(:disable_anonymous_search, type: :ops) && current_user.nil?
- return items.pg_full_text_search(search) if use_full_text_search?
+ return filter_by_full_text_search(items) if use_full_text_search?
if use_cte_for_search?
cte = Gitlab::SQL::CTE.new(klass.table_name, items)
@@ -348,12 +349,15 @@ class IssuableFinder
# rubocop: enable CodeReuse/ActiveRecord
def use_full_text_search?
- params[:in].blank? &&
- klass.try(:pg_full_text_searchable_columns).present? &&
+ klass.try(:pg_full_text_searchable_columns).present? &&
params[:search] =~ FULL_TEXT_SEARCH_TERM_REGEX &&
Feature.enabled?(:issues_full_text_search, params.project || params.group)
end
+ def filter_by_full_text_search(items)
+ items.pg_full_text_search(search, matched_columns: params[:in].to_s.split(','))
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def by_iids(items)
params[:iids].present? ? items.where(iid: params[:iids]) : items
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index 9f96abcd4e5..e12dce744b5 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -29,6 +29,8 @@
# issue_types: array of strings (one of WorkItems::Type.base_types)
#
class IssuesFinder < IssuableFinder
+ extend ::Gitlab::Utils::Override
+
CONFIDENTIAL_ACCESS_LEVEL = Gitlab::Access::REPORTER
def self.scalar_params
@@ -96,6 +98,16 @@ class IssuesFinder < IssuableFinder
by_negated_issue_types(issues)
end
+ override :filter_by_full_text_search
+ def filter_by_full_text_search(items)
+ # This project condition is used as a hint to PG about the partitions that need searching
+ # because the search data is partitioned by project.
+ # In certain cases, like the recent items search, the query plan is much better without this condition.
+ return super if params[:skip_full_text_search_project_condition].present?
+
+ super.with_projects_matching_search_data
+ end
+
def by_confidential(items)
return items if params[:confidential].nil?
diff --git a/app/finders/license_template_finder.rb b/app/finders/license_template_finder.rb
index b4235a77867..51457d443a1 100644
--- a/app/finders/license_template_finder.rb
+++ b/app/finders/license_template_finder.rb
@@ -34,9 +34,13 @@ class LicenseTemplateFinder
private
+ def available_licenses
+ Licensee::License.all(featured: popular_only?)
+ end
+
def vendored_licenses
strong_memoize(:vendored_licenses) do
- Licensee::License.all(featured: popular_only?).map do |license|
+ available_licenses.map do |license|
LicenseTemplate.new(
key: license.key,
name: license.name,
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 6bfe730ebc9..126687ae41f 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -27,7 +27,6 @@
# last_activity_after: datetime
# last_activity_before: datetime
# repository_storage: string
-# without_deleted: boolean
# not_aimed_for_deletion: boolean
#
class ProjectsFinder < UnionFinder
@@ -76,6 +75,7 @@ class ProjectsFinder < UnionFinder
# EE would override this to add more filters
def filter_projects(collection)
+ collection = collection.without_deleted
collection = by_ids(collection)
collection = by_personal(collection)
collection = by_starred(collection)
@@ -86,7 +86,6 @@ class ProjectsFinder < UnionFinder
collection = by_search(collection)
collection = by_archived(collection)
collection = by_custom_attributes(collection)
- collection = by_deleted_status(collection)
collection = by_not_aimed_for_deletion(collection)
collection = by_last_activity_after(collection)
collection = by_last_activity_before(collection)
@@ -212,10 +211,6 @@ class ProjectsFinder < UnionFinder
items.optionally_search(params[:search], include_namespace: params[:search_namespaces].present?)
end
- def by_deleted_status(items)
- params[:without_deleted].present? ? items.without_deleted : items
- end
-
def by_not_aimed_for_deletion(items)
params[:not_aimed_for_deletion].present? ? items.not_aimed_for_deletion : items
end
diff --git a/app/finders/users_star_projects_finder.rb b/app/finders/users_star_projects_finder.rb
index 7a7587c8631..ed36d0fb274 100644
--- a/app/finders/users_star_projects_finder.rb
+++ b/app/finders/users_star_projects_finder.rb
@@ -12,7 +12,7 @@ class UsersStarProjectsFinder
end
def execute
- stars = UsersStarProject.all
+ stars = UsersStarProject.with_active_user
stars = by_project(stars)
stars = by_search(stars)
filter_visible_profiles(stars)
diff --git a/app/finders/work_items/work_items_finder.rb b/app/finders/work_items/work_items_finder.rb
index 960272fe47e..62cca06bf5e 100644
--- a/app/finders/work_items/work_items_finder.rb
+++ b/app/finders/work_items/work_items_finder.rb
@@ -12,6 +12,30 @@ module WorkItems
private
+ def filter_items(items)
+ items = super(items)
+
+ by_widgets(items)
+ end
+
+ def by_widgets(items)
+ WorkItems::Type.available_widgets.each do |widget_class|
+ widget_filter = widget_filter_for(widget_class)
+
+ next unless widget_filter
+
+ items = widget_filter.filter(items, params)
+ end
+
+ items
+ end
+
+ def widget_filter_for(widget_class)
+ "WorkItems::Widgets::Filters::#{widget_class.name.demodulize.camelize}".constantize
+ rescue NameError
+ nil
+ end
+
def model_class
WorkItem
end
diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb
index dc4f838ae36..710e7fe110c 100644
--- a/app/graphql/graphql_triggers.rb
+++ b/app/graphql/graphql_triggers.rb
@@ -25,6 +25,10 @@ module GraphqlTriggers
GitlabSchema.subscriptions.trigger('issuableDatesUpdated', { issuable_id: issuable.to_gid }, issuable)
end
+ def self.issuable_milestone_updated(issuable)
+ GitlabSchema.subscriptions.trigger('issuableMilestoneUpdated', { issuable_id: issuable.to_gid }, issuable)
+ end
+
def self.merge_request_reviewers_updated(merge_request)
GitlabSchema.subscriptions.trigger(
'mergeRequestReviewersUpdated',
diff --git a/app/graphql/mutations/ci/job/artifacts_destroy.rb b/app/graphql/mutations/ci/job/artifacts_destroy.rb
index 34c58fc1240..9171299f353 100644
--- a/app/graphql/mutations/ci/job/artifacts_destroy.rb
+++ b/app/graphql/mutations/ci/job/artifacts_destroy.rb
@@ -18,7 +18,7 @@ module Mutations
null: false,
description: 'Number of artifacts deleted.'
- def find_object(id: )
+ def find_object(id:)
GlobalID::Locator.locate(id)
end
diff --git a/app/graphql/mutations/ci/job/base.rb b/app/graphql/mutations/ci/job/base.rb
index 6ea8e25a58d..f68f0507b28 100644
--- a/app/graphql/mutations/ci/job/base.rb
+++ b/app/graphql/mutations/ci/job/base.rb
@@ -10,7 +10,7 @@ module Mutations
required: true,
description: 'ID of the job to mutate.'
- def find_object(id: )
+ def find_object(id:)
GlobalID::Locator.locate(id)
end
end
diff --git a/app/graphql/mutations/ci/job_artifact/destroy.rb b/app/graphql/mutations/ci/job_artifact/destroy.rb
index 47b3535d631..add1b431fbf 100644
--- a/app/graphql/mutations/ci/job_artifact/destroy.rb
+++ b/app/graphql/mutations/ci/job_artifact/destroy.rb
@@ -20,7 +20,7 @@ module Mutations
null: true,
description: 'Deleted artifact.'
- def find_object(id: )
+ def find_object(id:)
GlobalID::Locator.locate(id)
end
diff --git a/app/graphql/mutations/ci/pipeline_schedule/take_ownership.rb b/app/graphql/mutations/ci/pipeline_schedule/take_ownership.rb
new file mode 100644
index 00000000000..2e4312f0045
--- /dev/null
+++ b/app/graphql/mutations/ci/pipeline_schedule/take_ownership.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Ci
+ module PipelineSchedule
+ class TakeOwnership < Base
+ graphql_name 'PipelineScheduleTakeOwnership'
+
+ authorize :take_ownership_pipeline_schedule
+
+ field :pipeline_schedule,
+ Types::Ci::PipelineScheduleType,
+ description: 'Updated pipeline schedule ownership.'
+
+ def resolve(id:)
+ schedule = authorized_find!(id: id)
+
+ service_response = ::Ci::PipelineSchedules::TakeOwnershipService.new(schedule, current_user).execute
+ {
+ pipeline_schedule: schedule,
+ errors: service_response.errors
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/ci/runner/bulk_delete.rb b/app/graphql/mutations/ci/runner/bulk_delete.rb
index 4265099d28e..f053eda0f55 100644
--- a/app/graphql/mutations/ci/runner/bulk_delete.rb
+++ b/app/graphql/mutations/ci/runner/bulk_delete.rb
@@ -25,13 +25,12 @@ module Mutations
'Only present if operation was performed synchronously.'
def resolve(**runner_attrs)
- raise_resource_not_available_error! unless Ability.allowed?(current_user, :delete_runners)
-
if ids = runner_attrs[:ids]
- runners = find_all_runners_by_ids(model_ids_of(ids))
+ runner_ids = model_ids_of(ids)
+ runners = find_all_runners_by_ids(runner_ids)
- result = ::Ci::Runners::BulkDeleteRunnersService.new(runners: runners).execute
- result.payload.slice(:deleted_count, :deleted_ids).merge(errors: [])
+ result = ::Ci::Runners::BulkDeleteRunnersService.new(runners: runners, current_user: current_user).execute
+ result.payload.slice(:deleted_count, :deleted_ids, :errors)
else
{ errors: [] }
end
@@ -39,14 +38,15 @@ module Mutations
private
- def model_ids_of(ids)
- ids.filter_map { |gid| gid.model_id.to_i }
+ def model_ids_of(global_ids)
+ global_ids.filter_map { |gid| gid.model_id.to_i }
end
def find_all_runners_by_ids(ids)
return ::Ci::Runner.none if ids.blank?
- ::Ci::Runner.id_in(ids)
+ limit = ::Ci::Runners::BulkDeleteRunnersService::RUNNER_LIMIT
+ ::Ci::Runner.id_in(ids).limit(limit + 1)
end
end
end
diff --git a/app/graphql/mutations/ci/runner/update.rb b/app/graphql/mutations/ci/runner/update.rb
index 2f2c8c4c668..3c99cde60a4 100644
--- a/app/graphql/mutations/ci/runner/update.rb
+++ b/app/graphql/mutations/ci/runner/update.rb
@@ -68,7 +68,7 @@ module Mutations
response = { runner: runner, errors: [] }
::Ci::Runner.transaction do
- associate_runner_projects(response, runner, associated_projects_ids) if associated_projects_ids.present?
+ associate_runner_projects(response, runner, associated_projects_ids) unless associated_projects_ids.nil?
update_runner(response, runner, runner_attrs)
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 e42e59de78f..6738f268e92 100644
--- a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
+++ b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
@@ -33,6 +33,9 @@ module Mutations
argument :labels_widget, ::Types::WorkItems::Widgets::LabelsUpdateInputType,
required: false,
description: 'Input for labels widget.'
+ argument :milestone_widget, ::Types::WorkItems::Widgets::MilestoneInputType,
+ required: false,
+ description: 'Input for milestone widget.'
end
end
end
diff --git a/app/graphql/mutations/container_repositories/destroy.rb b/app/graphql/mutations/container_repositories/destroy.rb
index 2a45291be22..fe1c3fe4e61 100644
--- a/app/graphql/mutations/container_repositories/destroy.rb
+++ b/app/graphql/mutations/container_repositories/destroy.rb
@@ -21,9 +21,11 @@ module Mutations
container_repository = authorized_find!(id: id)
container_repository.delete_scheduled!
- # rubocop:disable CodeReuse/Worker
- DeleteContainerRepositoryWorker.perform_async(current_user.id, container_repository.id)
- # rubocop:enable CodeReuse/Worker
+
+ unless Feature.enabled?(:container_registry_delete_repository_with_cron_worker)
+ DeleteContainerRepositoryWorker.perform_async(current_user.id, container_repository.id) # rubocop:disable CodeReuse/Worker
+ end
+
track_event(:delete_repository, :container)
{
diff --git a/app/graphql/mutations/incident_management/timeline_event/create.rb b/app/graphql/mutations/incident_management/timeline_event/create.rb
index 1907954cada..419b814dc8c 100644
--- a/app/graphql/mutations/incident_management/timeline_event/create.rb
+++ b/app/graphql/mutations/incident_management/timeline_event/create.rb
@@ -18,6 +18,10 @@ module Mutations
required: true,
description: 'Timestamp of when the event occurred.'
+ argument :timeline_event_tag_names, [GraphQL::Types::String],
+ required: false,
+ description: copy_field_description(Types::IncidentManagement::TimelineEventType, :timeline_event_tags)
+
def resolve(incident_id:, **args)
incident = authorized_find!(id: incident_id)
diff --git a/app/graphql/mutations/incident_management/timeline_event_tag/base.rb b/app/graphql/mutations/incident_management/timeline_event_tag/base.rb
new file mode 100644
index 00000000000..b1d07203ca1
--- /dev/null
+++ b/app/graphql/mutations/incident_management/timeline_event_tag/base.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Mutations
+ module IncidentManagement
+ module TimelineEventTag
+ class Base < BaseMutation
+ field :timeline_event_tag,
+ ::Types::IncidentManagement::TimelineEventTagType,
+ null: true,
+ description: 'Timeline event tag.'
+
+ authorize :admin_incident_management_timeline_event_tag
+
+ private
+
+ def response(result)
+ {
+ timeline_event_tag: result.payload[:timeline_event_tag],
+ errors: result.errors
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/incident_management/timeline_event_tag/create.rb b/app/graphql/mutations/incident_management/timeline_event_tag/create.rb
new file mode 100644
index 00000000000..14b1d288365
--- /dev/null
+++ b/app/graphql/mutations/incident_management/timeline_event_tag/create.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Mutations
+ module IncidentManagement
+ module TimelineEventTag
+ class Create < Base
+ graphql_name 'TimelineEventTagCreate'
+
+ include FindsProject
+
+ argument :project_path, GraphQL::Types::ID,
+ required: true,
+ description: 'Project to create the timeline event tag in.'
+
+ argument :name, GraphQL::Types::String,
+ required: true,
+ description: 'Name of the tag.'
+
+ def resolve(project_path:, **args)
+ project = authorized_find!(project_path)
+
+ response ::IncidentManagement::TimelineEventTags::CreateService.new(
+ project, current_user, args
+ ).execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb
index ece00e04ed9..793e5d3caf8 100644
--- a/app/graphql/mutations/work_items/create.rb
+++ b/app/graphql/mutations/work_items/create.rb
@@ -9,7 +9,7 @@ module Mutations
include FindsProject
include Mutations::WorkItems::Widgetable
- description "Creates a work item. Available only when feature flag `work_items` is enabled."
+ description "Creates a work item."
authorize :create_work_item
@@ -22,6 +22,9 @@ module Mutations
argument :hierarchy_widget, ::Types::WorkItems::Widgets::HierarchyCreateInputType,
required: false,
description: 'Input for hierarchy widget.'
+ argument :milestone_widget, ::Types::WorkItems::Widgets::MilestoneInputType,
+ required: false,
+ description: 'Input for milestone widget.'
argument :project_path, GraphQL::Types::ID,
required: true,
description: 'Full path of the project the work item is associated with.'
@@ -39,10 +42,6 @@ module Mutations
def resolve(project_path:, **attributes)
project = authorized_find!(project_path)
- unless project.work_items_feature_flag_enabled?
- return { errors: ['`work_items` feature flag disabled for this project'] }
- end
-
spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
params = global_id_compatibility_params(attributes).merge(author_id: current_user.id)
type = ::WorkItems::Type.find(attributes[:work_item_type_id])
diff --git a/app/graphql/mutations/work_items/create_from_task.rb b/app/graphql/mutations/work_items/create_from_task.rb
index 5ebe8b2c6d7..4ef8269a42f 100644
--- a/app/graphql/mutations/work_items/create_from_task.rb
+++ b/app/graphql/mutations/work_items/create_from_task.rb
@@ -7,8 +7,7 @@ module Mutations
include Mutations::SpamProtection
- description "Creates a work item from a task in another work item's description." \
- " Available only when feature flag `work_items` is enabled."
+ description "Creates a work item from a task in another work item's description."
authorize :update_work_item
@@ -31,10 +30,6 @@ module Mutations
def resolve(id:, work_item_data:)
work_item = authorized_find!(id: id)
- unless work_item.project.work_items_feature_flag_enabled?
- return { errors: ['`work_items` feature flag disabled for this project'] }
- end
-
spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
result = ::WorkItems::CreateFromTaskService.new(
diff --git a/app/graphql/mutations/work_items/delete.rb b/app/graphql/mutations/work_items/delete.rb
index 240a8b4c11e..4b0067d40d4 100644
--- a/app/graphql/mutations/work_items/delete.rb
+++ b/app/graphql/mutations/work_items/delete.rb
@@ -4,8 +4,7 @@ module Mutations
module WorkItems
class Delete < BaseMutation
graphql_name 'WorkItemDelete'
- description "Deletes a work item." \
- " Available only when feature flag `work_items` is enabled."
+ description "Deletes a work item."
authorize :delete_work_item
@@ -20,10 +19,6 @@ module Mutations
def resolve(id:)
work_item = authorized_find!(id: id)
- unless work_item.project.work_items_feature_flag_enabled?
- return { errors: ['`work_items` feature flag disabled for this project'] }
- end
-
result = ::WorkItems::DeleteService.new(
project: work_item.project,
current_user: current_user
diff --git a/app/graphql/mutations/work_items/delete_task.rb b/app/graphql/mutations/work_items/delete_task.rb
index b1bfed0cbf1..47ab3748ab4 100644
--- a/app/graphql/mutations/work_items/delete_task.rb
+++ b/app/graphql/mutations/work_items/delete_task.rb
@@ -5,8 +5,7 @@ module Mutations
class DeleteTask < BaseMutation
graphql_name 'WorkItemDeleteTask'
- description "Deletes a task in a work item's description." \
- ' Available only when feature flag `work_items` is enabled.'
+ description "Deletes a task in a work item's description."
authorize :update_work_item
@@ -29,10 +28,6 @@ module Mutations
work_item = authorized_find!(id: id)
task_data[:task] = authorized_find_task!(task_data[:id])
- unless work_item.project.work_items_feature_flag_enabled?
- return { errors: ['`work_items` feature flag disabled for this project'] }
- end
-
result = ::WorkItems::DeleteTaskService.new(
work_item: work_item,
current_user: current_user,
diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb
index b4ed0a1a3ca..04c63d8e876 100644
--- a/app/graphql/mutations/work_items/update.rb
+++ b/app/graphql/mutations/work_items/update.rb
@@ -4,8 +4,7 @@ module Mutations
module WorkItems
class Update < BaseMutation
graphql_name 'WorkItemUpdate'
- description "Updates a work item by Global ID." \
- " Available only when feature flag `work_items` is enabled."
+ description "Updates a work item by Global ID."
include Mutations::SpamProtection
include Mutations::WorkItems::UpdateArguments
@@ -20,10 +19,6 @@ module Mutations
def resolve(id:, **attributes)
work_item = authorized_find!(id: id)
- unless work_item.project.work_items_feature_flag_enabled?
- return { errors: ['`work_items` feature flag disabled for this project'] }
- end
-
spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
widget_params = extract_widget_params!(work_item.work_item_type, attributes)
diff --git a/app/graphql/mutations/work_items/update_task.rb b/app/graphql/mutations/work_items/update_task.rb
index 35fbe672b66..aeb4f1d0f06 100644
--- a/app/graphql/mutations/work_items/update_task.rb
+++ b/app/graphql/mutations/work_items/update_task.rb
@@ -4,8 +4,7 @@ module Mutations
module WorkItems
class UpdateTask < BaseMutation
graphql_name 'WorkItemUpdateTask'
- description "Updates a work item's task by Global ID." \
- " Available only when feature flag `work_items` is enabled."
+ description "Updates a work item's task by Global ID."
include Mutations::SpamProtection
@@ -30,10 +29,6 @@ module Mutations
work_item = authorized_find!(id: id)
task = authorized_find_task!(task_data_hash[:id])
- unless work_item.project.work_items_feature_flag_enabled?
- return { errors: ['`work_items` feature flag disabled for this project'] }
- end
-
spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
::WorkItems::UpdateService.new(
diff --git a/app/graphql/resolvers/base_issues_resolver.rb b/app/graphql/resolvers/base_issues_resolver.rb
deleted file mode 100644
index 6357132705e..00000000000
--- a/app/graphql/resolvers/base_issues_resolver.rb
+++ /dev/null
@@ -1,64 +0,0 @@
-# frozen_string_literal: true
-
-module Resolvers
- class BaseIssuesResolver < BaseResolver
- prepend IssueResolverArguments
-
- argument :sort, Types::IssueSortEnum,
- description: 'Sort issues by this criteria.',
- required: false,
- default_value: :created_desc
- argument :state, Types::IssuableStateEnum,
- required: false,
- description: 'Current state of this issue.'
-
- # see app/graphql/types/issue_connection.rb
- type 'Types::IssueConnection', null: true
-
- NON_STABLE_CURSOR_SORTS = %i[priority_asc priority_desc
- popularity_asc popularity_desc
- label_priority_asc label_priority_desc
- milestone_due_asc milestone_due_desc
- escalation_status_asc escalation_status_desc].freeze
-
- def continue_issue_resolve(parent, finder, **args)
- issues = Gitlab::Graphql::Loaders::IssuableLoader.new(parent, finder).batching_find_all { |q| apply_lookahead(q) }
-
- if non_stable_cursor_sort?(args[:sort])
- # Certain complex sorts are not supported by the stable cursor pagination yet.
- # In these cases, we use offset pagination, so we return the correct connection.
- offset_pagination(issues)
- else
- issues
- end
- end
-
- private
-
- def unconditional_includes
- [
- {
- project: [:project_feature, :group]
- },
- :author
- ]
- end
-
- def preloads
- {
- alert_management_alert: [:alert_management_alert],
- assignees: [:assignees],
- participants: Issue.participant_includes,
- timelogs: [:timelogs],
- customer_relations_contacts: { customer_relations_contacts: [:group] },
- escalation_status: [:incident_management_issuable_escalation_status]
- }
- end
-
- def non_stable_cursor_sort?(sort)
- NON_STABLE_CURSOR_SORTS.include?(sort)
- end
- end
-end
-
-Resolvers::BaseIssuesResolver.prepend_mod_with('Resolvers::BaseIssuesResolver')
diff --git a/app/graphql/resolvers/blobs_resolver.rb b/app/graphql/resolvers/blobs_resolver.rb
index 0704a845bb0..fb5fa4465f9 100644
--- a/app/graphql/resolvers/blobs_resolver.rb
+++ b/app/graphql/resolvers/blobs_resolver.rb
@@ -5,7 +5,7 @@ module Resolvers
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::Tree::BlobType.connection_type, null: true
- authorize :download_code
+ authorize :read_code
calls_gitaly!
alias_method :repository, :object
diff --git a/app/graphql/resolvers/bulk_labels_resolver.rb b/app/graphql/resolvers/bulk_labels_resolver.rb
index 7362e257fb6..d7e9564352d 100644
--- a/app/graphql/resolvers/bulk_labels_resolver.rb
+++ b/app/graphql/resolvers/bulk_labels_resolver.rb
@@ -9,7 +9,7 @@ module Resolvers
def resolve
authorize!(object)
- BatchLoader::GraphQL.for(object.id).batch(cache: false) do |ids, loader, args|
+ BatchLoader::GraphQL.for(object.id).batch(key: object.class.name, cache: false) do |ids, loader, args|
labels = Label.for_targets(object.class.id_in(ids)).group_by(&:target_id)
ids.each do |id|
diff --git a/app/graphql/resolvers/concerns/board_item_filterable.rb b/app/graphql/resolvers/concerns/board_item_filterable.rb
index 1457a02e44f..9c0ada4f72c 100644
--- a/app/graphql/resolvers/concerns/board_item_filterable.rb
+++ b/app/graphql/resolvers/concerns/board_item_filterable.rb
@@ -14,6 +14,16 @@ module BoardItemFilterable
set_filter_values(filters[:not])
end
+ if filters[:or]
+ if ::Feature.disabled?(:or_issuable_queries, resource_parent)
+ raise ::Gitlab::Graphql::Errors::ArgumentError,
+ "'or' arguments are only allowed when the `or_issuable_queries` feature flag is enabled."
+ end
+
+ rewrite_param_name(filters[:or], :author_usernames, :author_username)
+ rewrite_param_name(filters[:or], :assignee_usernames, :assignee_username)
+ end
+
filters
end
@@ -30,6 +40,14 @@ module BoardItemFilterable
filters[:assignee_id] = filters.delete(:assignee_wildcard_id)
end
end
+
+ def rewrite_param_name(filters, old_name, new_name)
+ filters[new_name] = filters.delete(old_name) if filters[old_name].present?
+ end
+
+ def resource_parent
+ respond_to?(:board) ? board.resource_parent : list.board.resource_parent
+ end
end
::BoardItemFilterable.prepend_mod_with('Resolvers::BoardItemFilterable')
diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
deleted file mode 100644
index 8295bd58388..00000000000
--- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
+++ /dev/null
@@ -1,167 +0,0 @@
-# frozen_string_literal: true
-
-module IssueResolverArguments
- extend ActiveSupport::Concern
-
- prepended do
- include SearchArguments
- include LooksAhead
-
- 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 issues. For example, `["1", "2"]`.'
- argument :label_name, [GraphQL::Types::String, null: true],
- required: false,
- description: 'Labels applied to this issue.'
- argument :milestone_title, [GraphQL::Types::String, null: true],
- required: false,
- description: 'Milestone applied to this issue.'
- argument :author_username, GraphQL::Types::String,
- required: false,
- description: 'Username of the author of the issue.'
- argument :assignee_username, GraphQL::Types::String,
- required: false,
- description: 'Username of a user assigned to the issue.',
- deprecated: { reason: 'Use `assigneeUsernames`', milestone: '13.11' }
- argument :assignee_usernames, [GraphQL::Types::String],
- required: false,
- description: 'Usernames of users assigned to the issue.'
- argument :assignee_id, GraphQL::Types::String,
- required: false,
- description: 'ID of a user assigned to the issues. Wildcard values "NONE" and "ANY" are supported.'
- argument :created_before, Types::TimeType,
- required: false,
- description: 'Issues created before this date.'
- argument :created_after, Types::TimeType,
- required: false,
- description: 'Issues created after this date.'
- argument :updated_before, Types::TimeType,
- required: false,
- description: 'Issues updated before this date.'
- argument :updated_after, Types::TimeType,
- required: false,
- description: 'Issues updated after this date.'
- argument :closed_before, Types::TimeType,
- required: false,
- description: 'Issues closed before this date.'
- argument :closed_after, Types::TimeType,
- required: false,
- description: 'Issues closed after this date.'
- argument :types, [Types::IssueTypeEnum],
- as: :issue_types,
- description: 'Filter issues by the given issue types.',
- required: false
- argument :milestone_wildcard_id, ::Types::MilestoneWildcardIdEnum,
- required: false,
- description: 'Filter issues by milestone ID wildcard.'
- argument :my_reaction_emoji, GraphQL::Types::String,
- required: false,
- description: 'Filter by reaction emoji applied by the current user. Wildcard values "NONE" and "ANY" are supported.'
- argument :confidential,
- GraphQL::Types::Boolean,
- required: false,
- description: 'Filter for confidential issues. If "false", excludes confidential issues. If "true", returns only confidential issues.'
- argument :not, Types::Issues::NegatedIssueFilterInputType,
- description: 'Negated arguments.',
- required: false
- argument :crm_contact_id, GraphQL::Types::String,
- required: false,
- description: 'ID of a contact assigned to the issues.'
- argument :crm_organization_id, GraphQL::Types::String,
- required: false,
- description: 'ID of an organization assigned to the issues.'
- end
-
- def resolve_with_lookahead(**args)
- return Issue.none if resource_parent.nil?
-
- finder = IssuesFinder.new(current_user, prepare_finder_params(args))
-
- continue_issue_resolve(resource_parent, finder, **args)
- end
-
- def ready?(**args)
- args[:not] = args[:not].to_h if args[:not].present?
-
- params_not_mutually_exclusive(args, mutually_exclusive_assignee_username_args)
- params_not_mutually_exclusive(args, mutually_exclusive_milestone_args)
- params_not_mutually_exclusive(args.fetch(:not, {}), mutually_exclusive_milestone_args)
- params_not_mutually_exclusive(args, mutually_exclusive_release_tag_args)
-
- super
- end
-
- class_methods do
- def resolver_complexity(args, child_complexity:)
- complexity = super
- complexity += 2 if args[:labelName]
-
- complexity
- end
-
- def accept_release_tag
- argument :release_tag, [GraphQL::Types::String],
- required: false,
- description: "Release tag associated with the issue's milestone."
- argument :release_tag_wildcard_id, Types::ReleaseTagWildcardIdEnum,
- required: false,
- description: 'Filter issues by release tag ID wildcard.'
- end
- end
-
- private
-
- def prepare_finder_params(args)
- params = super(args)
- params[:iids] ||= [params.delete(:iid)].compact if params[:iid]
- params[:attempt_project_search_optimizations] = true if params[:search].present?
-
- prepare_assignee_username_params(params)
- prepare_release_tag_params(params)
-
- params
- end
-
- def prepare_release_tag_params(args)
- release_tag_wildcard = args.delete(:release_tag_wildcard_id)
- return if release_tag_wildcard.blank?
-
- args[:release_tag] ||= release_tag_wildcard
- end
-
- def prepare_assignee_username_params(args)
- args[:assignee_username] = args.delete(:assignee_usernames) if args[:assignee_usernames].present?
- args[:not][:assignee_username] = args[:not].delete(:assignee_usernames) if args.dig(:not, :assignee_usernames).present?
- end
-
- def mutually_exclusive_release_tag_args
- [:release_tag, :release_tag_wildcard_id]
- end
-
- def mutually_exclusive_milestone_args
- [:milestone_title, :milestone_wildcard_id]
- end
-
- def mutually_exclusive_assignee_username_args
- [:assignee_usernames, :assignee_username]
- end
-
- def params_not_mutually_exclusive(args, mutually_exclusive_args)
- if args.slice(*mutually_exclusive_args).compact.size > 1
- arg_str = mutually_exclusive_args.map { |x| x.to_s.camelize(:lower) }.join(', ')
- raise ::Gitlab::Graphql::Errors::ArgumentError, "only one of [#{arg_str}] arguments is allowed at the same time."
- end
- end
-
- def resource_parent
- # The project could have been loaded in batch by `BatchLoader`.
- # At this point we need the `id` of the project to query for issues, so
- # make sure it's loaded and not `nil` before continuing.
- strong_memoize(:resource_parent) do
- object.respond_to?(:sync) ? object.sync : object
- end
- end
-end
diff --git a/app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb b/app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb
new file mode 100644
index 00000000000..c6e32be245d
--- /dev/null
+++ b/app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Issues
+ module LookAheadPreloads
+ extend ActiveSupport::Concern
+
+ prepended do
+ include ::LooksAhead
+ end
+
+ private
+
+ def unconditional_includes
+ [
+ {
+ project: [:project_feature, :group]
+ },
+ :author
+ ]
+ end
+
+ def preloads
+ {
+ alert_management_alert: [:alert_management_alert],
+ assignees: [:assignees],
+ participants: Issue.participant_includes,
+ timelogs: [:timelogs],
+ customer_relations_contacts: { customer_relations_contacts: [:group] },
+ escalation_status: [:incident_management_issuable_escalation_status]
+ }
+ end
+ end
+end
+
+Issues::LookAheadPreloads.prepend_mod
diff --git a/app/graphql/resolvers/concerns/issues/sort_arguments.rb b/app/graphql/resolvers/concerns/issues/sort_arguments.rb
new file mode 100644
index 00000000000..70ae6bd8a5b
--- /dev/null
+++ b/app/graphql/resolvers/concerns/issues/sort_arguments.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Issues
+ module SortArguments
+ extend ActiveSupport::Concern
+
+ NON_STABLE_CURSOR_SORTS = %i[priority_asc priority_desc
+ popularity_asc popularity_desc
+ label_priority_asc label_priority_desc
+ milestone_due_asc milestone_due_desc
+ escalation_status_asc escalation_status_desc].freeze
+
+ included do
+ argument :sort, Types::IssueSortEnum,
+ description: 'Sort issues by this criteria.',
+ required: false,
+ default_value: :created_desc
+ end
+
+ private
+
+ def non_stable_cursor_sort?(sort)
+ NON_STABLE_CURSOR_SORTS.include?(sort)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/concerns/project_search_arguments.rb b/app/graphql/resolvers/concerns/project_search_arguments.rb
index 7e03963f412..faf3b85fc14 100644
--- a/app/graphql/resolvers/concerns/project_search_arguments.rb
+++ b/app/graphql/resolvers/concerns/project_search_arguments.rb
@@ -25,7 +25,6 @@ module ProjectSearchArguments
def project_finder_params(params)
{
- without_deleted: true,
non_public: params[:membership],
search: params[:search],
search_namespaces: params[:search_namespaces],
diff --git a/app/graphql/resolvers/concerns/search_arguments.rb b/app/graphql/resolvers/concerns/search_arguments.rb
index 95c6dbf7497..ccc012f2bf9 100644
--- a/app/graphql/resolvers/concerns/search_arguments.rb
+++ b/app/graphql/resolvers/concerns/search_arguments.rb
@@ -46,9 +46,17 @@ module SearchArguments
def prepare_search_params(args)
return args unless args[:search].present?
+ args[:in] = args[:in].join(',') if args[:in].present?
+ set_search_optimization_param(args)
+
+ args
+ end
+
+ def set_search_optimization_param(args)
+ return args unless respond_to?(:resource_parent, true) && resource_parent.present?
+
parent_type = resource_parent.is_a?(Project) ? :project : :group
args[:"attempt_#{parent_type}_search_optimizations"] = true
- args[:in] = args[:in].join(',') if args[:in].present?
args
end
diff --git a/app/graphql/resolvers/group_issues_resolver.rb b/app/graphql/resolvers/group_issues_resolver.rb
index 05c5e803539..43f01395896 100644
--- a/app/graphql/resolvers/group_issues_resolver.rb
+++ b/app/graphql/resolvers/group_issues_resolver.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
-# rubocop:disable Graphql/ResolverType (inherited from BaseIssuesResolver)
+# rubocop:disable Graphql/ResolverType (inherited from Issues::BaseParentResolver)
module Resolvers
- class GroupIssuesResolver < BaseIssuesResolver
+ class GroupIssuesResolver < Issues::BaseParentResolver
def self.issuable_collection_name
'issues'
end
@@ -18,3 +18,4 @@ module Resolvers
end
end
end
+# rubocop:enable Graphql/ResolverType
diff --git a/app/graphql/resolvers/incident_management/timeline_event_tags_resolver.rb b/app/graphql/resolvers/incident_management/timeline_event_tags_resolver.rb
new file mode 100644
index 00000000000..ac6577d119b
--- /dev/null
+++ b/app/graphql/resolvers/incident_management/timeline_event_tags_resolver.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module IncidentManagement
+ class TimelineEventTagsResolver < BaseResolver
+ include LooksAhead
+
+ type ::Types::IncidentManagement::TimelineEventTagType.connection_type, null: true
+
+ def resolve(**args)
+ apply_lookahead(::IncidentManagement::TimelineEventTagsFinder.new(current_user, object, args).execute)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/incident_management/timeline_events_resolver.rb b/app/graphql/resolvers/incident_management/timeline_events_resolver.rb
index b9978259e6b..0d46b1387b0 100644
--- a/app/graphql/resolvers/incident_management/timeline_events_resolver.rb
+++ b/app/graphql/resolvers/incident_management/timeline_events_resolver.rb
@@ -22,11 +22,17 @@ module Resolvers
prepare: ->(id, ctx) { id.model_id }
end
- def resolve(**args)
+ def resolve_with_lookahead(**args)
incident = args[:incident_id].find
apply_lookahead(::IncidentManagement::TimelineEventsFinder.new(current_user, incident, args).execute)
end
+
+ def preloads
+ {
+ timeline_event_tags: [:timeline_event_tags]
+ }
+ end
end
end
end
diff --git a/app/graphql/resolvers/issue_status_counts_resolver.rb b/app/graphql/resolvers/issue_status_counts_resolver.rb
index db5c91daac2..92cda77d717 100644
--- a/app/graphql/resolvers/issue_status_counts_resolver.rb
+++ b/app/graphql/resolvers/issue_status_counts_resolver.rb
@@ -1,17 +1,29 @@
# frozen_string_literal: true
module Resolvers
- class IssueStatusCountsResolver < BaseResolver
- prepend IssueResolverArguments
-
+ class IssueStatusCountsResolver < Issues::BaseResolver
type Types::IssueStatusCountsType, null: true
+
accept_release_tag
- extras [:lookahead]
+ def resolve(**args)
+ return Issue.none if resource_parent.nil?
+
+ finder = IssuesFinder.new(current_user, prepare_finder_params(args))
+ finder.parent_param = resource_parent
+
+ Gitlab::IssuablesCountForState.new(finder, resource_parent)
+ end
+
+ private
- def continue_issue_resolve(parent, finder, **args)
- finder.parent_param = parent
- apply_lookahead(Gitlab::IssuablesCountForState.new(finder, parent))
+ def resource_parent
+ # The project could have been loaded in batch by `BatchLoader`.
+ # At this point we need the `id` of the project to query for issues, so
+ # make sure it's loaded and not `nil` before continuing.
+ strong_memoize(:resource_parent) do
+ object.respond_to?(:sync) ? object.sync : object
+ end
end
end
end
diff --git a/app/graphql/resolvers/issues/base_parent_resolver.rb b/app/graphql/resolvers/issues/base_parent_resolver.rb
new file mode 100644
index 00000000000..6308e56f049
--- /dev/null
+++ b/app/graphql/resolvers/issues/base_parent_resolver.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Issues
+ class BaseParentResolver < Issues::BaseResolver
+ prepend ::Issues::LookAheadPreloads
+ include ::Issues::SortArguments
+
+ argument :state, Types::IssuableStateEnum,
+ required: false,
+ description: 'Current state of this issue.'
+
+ # see app/graphql/types/issue_connection.rb
+ type 'Types::IssueConnection', null: true
+
+ def resolve_with_lookahead(**args)
+ return Issue.none if resource_parent.nil?
+
+ finder = IssuesFinder.new(current_user, prepare_finder_params(args))
+
+ issues = Gitlab::Graphql::Loaders::IssuableLoader.new(resource_parent, finder).batching_find_all do |q|
+ apply_lookahead(q)
+ end
+
+ if non_stable_cursor_sort?(args[:sort])
+ # Certain complex sorts are not supported by the stable cursor pagination yet.
+ # In these cases, we use offset pagination, so we return the correct connection.
+ offset_pagination(issues)
+ else
+ issues
+ end
+ end
+
+ private
+
+ def resource_parent
+ # The project could have been loaded in batch by `BatchLoader`.
+ # At this point we need the `id` of the project to query for issues, so
+ # make sure it's loaded and not `nil` before continuing.
+ strong_memoize(:resource_parent) do
+ object.respond_to?(:sync) ? object.sync : object
+ end
+ end
+ end
+ end
+end
+
+Resolvers::Issues::BaseParentResolver.prepend_mod
diff --git a/app/graphql/resolvers/issues/base_resolver.rb b/app/graphql/resolvers/issues/base_resolver.rb
new file mode 100644
index 00000000000..9a2c4572abb
--- /dev/null
+++ b/app/graphql/resolvers/issues/base_resolver.rb
@@ -0,0 +1,186 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Issues
+ # rubocop:disable Graphql/ResolverType
+ class BaseResolver < Resolvers::BaseResolver
+ include SearchArguments
+
+ argument :assignee_id, GraphQL::Types::String,
+ required: false,
+ description: 'ID of a user assigned to the issues. Wildcard values "NONE" and "ANY" are supported.'
+ argument :assignee_username, GraphQL::Types::String,
+ required: false,
+ description: 'Username of a user assigned to the issue.',
+ deprecated: { reason: 'Use `assigneeUsernames`', milestone: '13.11' }
+ 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 :closed_after, Types::TimeType,
+ required: false,
+ description: 'Issues closed after this date.'
+ argument :closed_before, Types::TimeType,
+ required: false,
+ description: 'Issues closed before this date.'
+ argument :confidential,
+ GraphQL::Types::Boolean,
+ required: false,
+ description: 'Filter for confidential issues. If "false", excludes confidential issues.' \
+ ' If "true", returns only confidential issues.'
+ argument :created_after, Types::TimeType,
+ required: false,
+ description: 'Issues created after this date.'
+ argument :created_before, Types::TimeType,
+ required: false,
+ description: 'Issues created before this date.'
+ argument :crm_contact_id, GraphQL::Types::String,
+ required: false,
+ description: 'ID of a contact assigned to the issues.'
+ argument :crm_organization_id, GraphQL::Types::String,
+ required: false,
+ description: 'ID of an organization assigned to the issues.'
+ 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 issues. For example, `["1", "2"]`.'
+ argument :label_name, [GraphQL::Types::String, { null: true }],
+ required: false,
+ description: 'Labels applied to this issue.'
+ argument :milestone_title, [GraphQL::Types::String, { null: true }],
+ required: false,
+ description: 'Milestone applied to this issue.'
+ argument :milestone_wildcard_id, ::Types::MilestoneWildcardIdEnum,
+ required: false,
+ description: 'Filter issues by milestone ID wildcard.'
+ argument :my_reaction_emoji, GraphQL::Types::String,
+ required: false,
+ description: 'Filter by reaction emoji applied by the current user.' \
+ ' Wildcard values "NONE" and "ANY" are supported.'
+ argument :not, Types::Issues::NegatedIssueFilterInputType,
+ description: 'Negated arguments.',
+ required: false
+ argument :or, Types::Issues::UnionedIssueFilterInputType,
+ description: 'List of arguments with inclusive OR.',
+ required: false
+ argument :types, [Types::IssueTypeEnum],
+ as: :issue_types,
+ description: 'Filter issues by the given issue types.',
+ required: false
+ argument :updated_after, Types::TimeType,
+ required: false,
+ description: 'Issues updated after this date.'
+ argument :updated_before, Types::TimeType,
+ required: false,
+ description: 'Issues updated before this date.'
+
+ class << self
+ def resolver_complexity(args, child_complexity:)
+ complexity = super
+ complexity += 2 if args[:labelName]
+
+ complexity
+ end
+
+ def accept_release_tag
+ argument :release_tag, [GraphQL::Types::String],
+ required: false,
+ description: "Release tag associated with the issue's milestone."
+ argument :release_tag_wildcard_id, Types::ReleaseTagWildcardIdEnum,
+ required: false,
+ description: 'Filter issues by release tag ID wildcard.'
+ end
+ end
+
+ def ready?(**args)
+ if args[:or].present? && or_issuable_queries_disabled?
+ raise ::Gitlab::Graphql::Errors::ArgumentError,
+ "'or' arguments are only allowed when the `or_issuable_queries` feature flag is enabled."
+ end
+
+ args[:not] = args[:not].to_h if args[:not]
+ args[:or] = args[:or].to_h if args[:or]
+
+ params_not_mutually_exclusive(args, mutually_exclusive_assignee_username_args)
+ params_not_mutually_exclusive(args, mutually_exclusive_milestone_args)
+ params_not_mutually_exclusive(args.fetch(:not, {}), mutually_exclusive_milestone_args)
+ params_not_mutually_exclusive(args, mutually_exclusive_release_tag_args)
+
+ super
+ end
+
+ private
+
+ def or_issuable_queries_disabled?
+ if respond_to?(:resource_parent, true)
+ ::Feature.disabled?(:or_issuable_queries, resource_parent)
+ else
+ ::Feature.disabled?(:or_issuable_queries)
+ end
+ end
+
+ def prepare_finder_params(args)
+ params = super(args)
+ params[:not] = params[:not].to_h if params[:not]
+ params[:or] = params[:or].to_h if params[:or]
+ params[:iids] ||= [params.delete(:iid)].compact if params[:iid]
+
+ prepare_author_username_params(params)
+ prepare_assignee_username_params(params)
+ prepare_release_tag_params(params)
+
+ params
+ end
+
+ def prepare_release_tag_params(args)
+ release_tag_wildcard = args.delete(:release_tag_wildcard_id)
+ return if release_tag_wildcard.blank?
+
+ args[:release_tag] ||= release_tag_wildcard
+ end
+
+ def prepare_author_username_params(args)
+ args[:or][:author_username] = args[:or].delete(:author_usernames) if args.dig(:or, :author_usernames).present?
+ end
+
+ def prepare_assignee_username_params(args)
+ args[:assignee_username] = args.delete(:assignee_usernames) if args[:assignee_usernames].present?
+
+ if args.dig(:or, :assignee_usernames).present?
+ args[:or][:assignee_username] = args[:or].delete(:assignee_usernames)
+ end
+
+ return unless args.dig(:not, :assignee_usernames).present?
+
+ args[:not][:assignee_username] = args[:not].delete(:assignee_usernames)
+ end
+
+ def mutually_exclusive_release_tag_args
+ [:release_tag, :release_tag_wildcard_id]
+ end
+
+ def mutually_exclusive_milestone_args
+ [:milestone_title, :milestone_wildcard_id]
+ end
+
+ def mutually_exclusive_assignee_username_args
+ [:assignee_usernames, :assignee_username]
+ end
+
+ def params_not_mutually_exclusive(args, mutually_exclusive_args)
+ return unless args.slice(*mutually_exclusive_args).compact.size > 1
+
+ arg_str = mutually_exclusive_args.map { |x| x.to_s.camelize(:lower) }.join(', ')
+ raise ::Gitlab::Graphql::Errors::ArgumentError,
+ "only one of [#{arg_str}] arguments is allowed at the same time."
+ end
+ end
+ # rubocop:enable Graphql/ResolverType
+ end
+end
+
+Resolvers::Issues::BaseResolver.prepend_mod
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index 4b52ef61d57..e3102a7d32a 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -1,8 +1,31 @@
# frozen_string_literal: true
-# rubocop:disable Graphql/ResolverType (inherited from BaseIssuesResolver)
module Resolvers
- class IssuesResolver < BaseIssuesResolver
- accept_release_tag
+ class IssuesResolver < Issues::BaseResolver
+ prepend ::Issues::LookAheadPreloads
+ include ::Issues::SortArguments
+
+ argument :state, Types::IssuableStateEnum,
+ required: false,
+ description: 'Current state of this issue.'
+
+ # see app/graphql/types/issue_connection.rb
+ type 'Types::IssueConnection', null: true
+
+ def resolve_with_lookahead(**args)
+ return unless Feature.enabled?(:root_level_issues_query)
+
+ issues = apply_lookahead(
+ IssuesFinder.new(current_user, prepare_finder_params(args)).execute
+ )
+
+ if non_stable_cursor_sort?(args[:sort])
+ # Certain complex sorts are not supported by the stable cursor pagination yet.
+ # In these cases, we use offset pagination, so we return the correct connection.
+ offset_pagination(issues)
+ else
+ issues
+ end
+ end
end
end
diff --git a/app/graphql/resolvers/project_issues_resolver.rb b/app/graphql/resolvers/project_issues_resolver.rb
new file mode 100644
index 00000000000..f869d8f11c6
--- /dev/null
+++ b/app/graphql/resolvers/project_issues_resolver.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+# rubocop:disable Graphql/ResolverType (inherited from Issues::BaseParentResolver)
+module Resolvers
+ class ProjectIssuesResolver < Issues::BaseParentResolver
+ accept_release_tag
+ end
+end
+# rubocop:enable Graphql/ResolverType
diff --git a/app/graphql/resolvers/work_item_resolver.rb b/app/graphql/resolvers/work_item_resolver.rb
index 9eb7d6bc693..b174a0d2693 100644
--- a/app/graphql/resolvers/work_item_resolver.rb
+++ b/app/graphql/resolvers/work_item_resolver.rb
@@ -11,10 +11,7 @@ module Resolvers
argument :id, ::Types::GlobalIDType[::WorkItem], required: true, description: 'Global ID of the work item.'
def resolve(id:)
- work_item = authorized_find!(id: id)
- return unless work_item.project.work_items_feature_flag_enabled?
-
- work_item
+ authorized_find!(id: id)
end
private
diff --git a/app/graphql/resolvers/work_items/types_resolver.rb b/app/graphql/resolvers/work_items/types_resolver.rb
index 5f9f8ab5572..2508125d392 100644
--- a/app/graphql/resolvers/work_items/types_resolver.rb
+++ b/app/graphql/resolvers/work_items/types_resolver.rb
@@ -11,8 +11,6 @@ module Resolvers
' Argument is experimental and can be removed in the future without notice.'
def resolve(taskable: nil)
- return unless feature_flag_enabled_for_parent?(object)
-
# This will require a finder in the future when groups/projects get their work item types
# All groups/projects use the default types for now
base_scope = ::WorkItems::Type.default
@@ -20,14 +18,6 @@ module Resolvers
base_scope.order_by_name_asc
end
-
- private
-
- def feature_flag_enabled_for_parent?(parent)
- return false unless parent.is_a?(::Project) || parent.is_a?(::Group)
-
- parent.work_items_feature_flag_enabled?
- end
end
end
end
diff --git a/app/graphql/resolvers/work_items_resolver.rb b/app/graphql/resolvers/work_items_resolver.rb
index a4cbcc61ead..42f4f99d4a9 100644
--- a/app/graphql/resolvers/work_items_resolver.rb
+++ b/app/graphql/resolvers/work_items_resolver.rb
@@ -26,7 +26,7 @@ module Resolvers
required: false
def resolve_with_lookahead(**args)
- return WorkItem.none if resource_parent.nil? || !resource_parent.work_items_feature_flag_enabled?
+ return WorkItem.none if resource_parent.nil?
finder = ::WorkItems::WorkItemsFinder.new(current_user, prepare_finder_params(args))
@@ -55,7 +55,8 @@ module Resolvers
last_edited_by: :last_edited_by,
assignees: :assignees,
parent: :work_item_parent,
- labels: :labels
+ labels: :labels,
+ milestone: :milestone
}
end
diff --git a/app/graphql/types/base_argument.rb b/app/graphql/types/base_argument.rb
index 2c899e9edaa..4086015dad6 100644
--- a/app/graphql/types/base_argument.rb
+++ b/app/graphql/types/base_argument.rb
@@ -4,10 +4,10 @@ module Types
class BaseArgument < GraphQL::Schema::Argument
include GitlabStyleDeprecations
- attr_reader :deprecation, :doc_reference
+ attr_reader :doc_reference
def initialize(*args, **kwargs, &block)
- @deprecation = gitlab_deprecation(kwargs)
+ init_gitlab_deprecation(kwargs)
@doc_reference = kwargs.delete(:see)
# our custom addition `nullable` which allows us to declare
diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb
index 0224aeddac6..11877b79e59 100644
--- a/app/graphql/types/base_enum.rb
+++ b/app/graphql/types/base_enum.rb
@@ -6,10 +6,8 @@ module Types
class CustomValue < GraphQL::Schema::EnumValue
include ::GitlabStyleDeprecations
- attr_reader :deprecation
-
def initialize(name, desc = nil, **kwargs)
- @deprecation = gitlab_deprecation(kwargs)
+ init_gitlab_deprecation(kwargs)
super(name, desc, **kwargs)
end
diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb
index 6f64e5b5053..36ba3399754 100644
--- a/app/graphql/types/base_field.rb
+++ b/app/graphql/types/base_field.rb
@@ -8,16 +8,16 @@ module Types
DEFAULT_COMPLEXITY = 1
- attr_reader :deprecation, :doc_reference
+ attr_reader :doc_reference
def initialize(**kwargs, &block)
+ init_gitlab_deprecation(kwargs)
@calls_gitaly = !!kwargs.delete(:calls_gitaly)
@doc_reference = kwargs.delete(:see)
@constant_complexity = kwargs[:complexity].is_a?(Integer) && kwargs[:complexity] > 0
@requires_argument = !!kwargs.delete(:requires_argument)
@authorize = Array.wrap(kwargs.delete(:authorize))
kwargs[:complexity] = field_complexity(kwargs[:resolver_class], kwargs[:complexity])
- @deprecation = gitlab_deprecation(kwargs)
after_connection_extensions = kwargs.delete(:late_extensions) || []
super(**kwargs, &block)
diff --git a/app/graphql/types/boards/board_issue_input_type.rb b/app/graphql/types/boards/board_issue_input_type.rb
index 0dd7fbc87da..897e3d05948 100644
--- a/app/graphql/types/boards/board_issue_input_type.rb
+++ b/app/graphql/types/boards/board_issue_input_type.rb
@@ -9,6 +9,10 @@ module Types
required: false,
description: 'List of negated arguments.'
+ argument :or, Types::Issues::UnionedIssueFilterInputType,
+ required: false,
+ description: 'List of arguments with inclusive OR.'
+
argument :search, GraphQL::Types::String,
required: false,
description: 'Search query for issue title or description.'
diff --git a/app/graphql/types/branch_protections/merge_access_level_type.rb b/app/graphql/types/branch_protections/merge_access_level_type.rb
index 85295e1ba25..e8fcd57ba80 100644
--- a/app/graphql/types/branch_protections/merge_access_level_type.rb
+++ b/app/graphql/types/branch_protections/merge_access_level_type.rb
@@ -4,7 +4,7 @@ module Types
module BranchProtections
class MergeAccessLevelType < BaseAccessLevelType # rubocop:disable Graphql/AuthorizeTypes
graphql_name 'MergeAccessLevel'
- description 'Represents the merge access level of a branch protection.'
+ description 'Defines which user roles, users, or groups can merge into a protected branch.'
accepts ::ProtectedBranch::MergeAccessLevel
end
end
diff --git a/app/graphql/types/branch_protections/push_access_level_type.rb b/app/graphql/types/branch_protections/push_access_level_type.rb
index bfbdc4edbea..c5e21fad88d 100644
--- a/app/graphql/types/branch_protections/push_access_level_type.rb
+++ b/app/graphql/types/branch_protections/push_access_level_type.rb
@@ -4,7 +4,7 @@ module Types
module BranchProtections
class PushAccessLevelType < BaseAccessLevelType # rubocop:disable Graphql/AuthorizeTypes
graphql_name 'PushAccessLevel'
- description 'Represents the push access level of a branch protection.'
+ description 'Defines which user roles, users, or groups can push to a protected branch.'
accepts ::ProtectedBranch::PushAccessLevel
end
end
diff --git a/app/graphql/types/ci/job_need_union.rb b/app/graphql/types/ci/job_need_union.rb
index 59608a6a312..61ad5432db8 100644
--- a/app/graphql/types/ci/job_need_union.rb
+++ b/app/graphql/types/ci/job_need_union.rb
@@ -8,9 +8,10 @@ module Types
possible_types Types::Ci::JobType, Types::Ci::BuildNeedType
def self.resolve_type(object, context)
- if object.is_a?(::Ci::BuildNeed)
+ case object
+ when ::Ci::BuildNeed
Types::Ci::BuildNeedType
- elsif object.is_a?(CommitStatus)
+ when CommitStatus
Types::Ci::JobType
else
raise TypeNotSupportedError
diff --git a/app/graphql/types/commit_signature_interface.rb b/app/graphql/types/commit_signature_interface.rb
new file mode 100644
index 00000000000..6b0c16e538a
--- /dev/null
+++ b/app/graphql/types/commit_signature_interface.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Types
+ module CommitSignatureInterface
+ include Types::BaseInterface
+
+ graphql_name 'CommitSignature'
+
+ description 'Represents signing information for a commit'
+
+ field :verification_status, CommitSignatures::VerificationStatusEnum,
+ null: true,
+ description: 'Indicates verification status of the associated key or certificate.'
+
+ field :commit_sha, GraphQL::Types::String,
+ null: true,
+ description: 'SHA of the associated commit.'
+
+ field :project, Types::ProjectType,
+ null: true,
+ description: 'Project of the associated commit.'
+
+ orphan_types Types::CommitSignatures::GpgSignatureType,
+ Types::CommitSignatures::X509SignatureType
+
+ def self.resolve_type(object, context)
+ case object
+ when ::CommitSignatures::GpgSignature
+ Types::CommitSignatures::GpgSignatureType
+ when ::CommitSignatures::X509CommitSignature
+ Types::CommitSignatures::X509SignatureType
+ else
+ raise 'Unsupported commit signature type'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/commit_signatures/gpg_signature_type.rb b/app/graphql/types/commit_signatures/gpg_signature_type.rb
new file mode 100644
index 00000000000..2a845fff3e2
--- /dev/null
+++ b/app/graphql/types/commit_signatures/gpg_signature_type.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Types
+ module CommitSignatures
+ class GpgSignatureType < Types::BaseObject
+ graphql_name 'GpgSignature'
+ description 'GPG signature for a signed commit'
+
+ implements Types::CommitSignatureInterface
+
+ authorize :download_code
+
+ field :user, Types::UserType, null: true,
+ description: 'User associated with the key.'
+
+ field :gpg_key_user_name, GraphQL::Types::String,
+ null: true,
+ description: 'User name associated with the GPG key.'
+
+ field :gpg_key_user_email, GraphQL::Types::String,
+ null: true,
+ description: 'User email associated with the GPG key.'
+
+ field :gpg_key_primary_keyid, GraphQL::Types::String,
+ null: true,
+ description: 'ID of the GPG key.'
+ end
+ end
+end
diff --git a/app/graphql/types/commit_signatures/verification_status_enum.rb b/app/graphql/types/commit_signatures/verification_status_enum.rb
new file mode 100644
index 00000000000..9df1b7abd82
--- /dev/null
+++ b/app/graphql/types/commit_signatures/verification_status_enum.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+# rubocop:disable Graphql/AuthorizeTypes
+
+module Types
+ module CommitSignatures
+ class VerificationStatusEnum < BaseEnum
+ graphql_name 'VerificationStatus'
+ description 'Verification status of a GPG or X.509 signature for a commit.'
+
+ ::CommitSignatures::GpgSignature.verification_statuses.each do |status, _|
+ value status.upcase, value: status, description: "#{status} verification status."
+ end
+ end
+ end
+end
+
+# rubocop:enable Graphql/AuthorizeTypes
diff --git a/app/graphql/types/commit_signatures/x509_signature_type.rb b/app/graphql/types/commit_signatures/x509_signature_type.rb
new file mode 100644
index 00000000000..9ac96dbc015
--- /dev/null
+++ b/app/graphql/types/commit_signatures/x509_signature_type.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Types
+ module CommitSignatures
+ class X509SignatureType < Types::BaseObject
+ graphql_name 'X509Signature'
+ description 'X.509 signature for a signed commit'
+
+ implements Types::CommitSignatureInterface
+
+ authorize :download_code
+
+ field :user, Types::UserType, null: true,
+ calls_gitaly: true,
+ description: 'User associated with the key.'
+
+ field :x509_certificate, Types::X509CertificateType,
+ null: true,
+ description: 'Certificate used for the signature.'
+ end
+ end
+end
diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb
index dfb02f29fb7..5dd862c7388 100644
--- a/app/graphql/types/commit_type.rb
+++ b/app/graphql/types/commit_type.rb
@@ -4,7 +4,7 @@ module Types
class CommitType < BaseObject
graphql_name 'Commit'
- authorize :download_code
+ authorize :read_code
present_using CommitPresenter
@@ -40,6 +40,11 @@ module Types
field :web_path, type: GraphQL::Types::String, null: false,
description: 'Web path of the commit.'
+ field :signature, type: Types::CommitSignatureInterface,
+ null: true,
+ calls_gitaly: true,
+ description: 'Signature of the commit.'
+
field :signature_html, type: GraphQL::Types::String, null: true, calls_gitaly: true,
description: 'Rendered HTML of the commit signature.'
diff --git a/app/graphql/types/concerns/gitlab_style_deprecations.rb b/app/graphql/types/concerns/gitlab_style_deprecations.rb
index e404f1fcad9..859a27cac4c 100644
--- a/app/graphql/types/concerns/gitlab_style_deprecations.rb
+++ b/app/graphql/types/concerns/gitlab_style_deprecations.rb
@@ -1,14 +1,22 @@
# frozen_string_literal: true
-# Concern for handling deprecation arguments.
+# Concern for handling GraphQL deprecations.
# https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-schema-items
module GitlabStyleDeprecations
extend ActiveSupport::Concern
+ included do
+ attr_accessor :deprecation
+ end
+
+ def visible?(ctx)
+ super && ctx[:remove_deprecated] == true ? deprecation.nil? : true
+ end
+
private
- # Mutate the arguments, returns the deprecation
- def gitlab_deprecation(kwargs)
+ # Set deprecation, mutate the arguments
+ def init_gitlab_deprecation(kwargs)
if kwargs[:deprecation_reason].present?
raise ArgumentError, 'Use `deprecated` property instead of `deprecation_reason`. ' \
'See https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-schema-items'
@@ -17,14 +25,12 @@ module GitlabStyleDeprecations
# GitLab allows items to be marked as "alpha", which leverages GraphQL deprecations.
deprecation_args = kwargs.extract!(:alpha, :deprecated)
- deprecation = ::Gitlab::Graphql::Deprecation.parse(**deprecation_args)
+ self.deprecation = ::Gitlab::Graphql::Deprecation.parse(**deprecation_args)
return unless deprecation
raise ArgumentError, "Bad deprecation. #{deprecation.errors.full_messages.to_sentence}" unless deprecation.valid?
kwargs[:deprecation_reason] = deprecation.deprecation_reason
kwargs[:description] = deprecation.edit_description(kwargs[:description])
-
- deprecation
end
end
diff --git a/app/graphql/types/deployment_details_type.rb b/app/graphql/types/deployment_details_type.rb
index f8ba0cb1b24..bbb5cc8e3f1 100644
--- a/app/graphql/types/deployment_details_type.rb
+++ b/app/graphql/types/deployment_details_type.rb
@@ -5,7 +5,7 @@ module Types
graphql_name 'DeploymentDetails'
description 'The details of the deployment'
authorize :read_deployment
- present_using Deployments::DeploymentPresenter
+ present_using ::Deployments::DeploymentPresenter
field :tags,
[Types::DeploymentTagType],
@@ -13,3 +13,5 @@ module Types
calls_gitaly: true
end
end
+
+Types::DeploymentDetailsType.prepend_mod_with('Types::DeploymentDetailsType')
diff --git a/app/graphql/types/deployment_type.rb b/app/graphql/types/deployment_type.rb
index 70a3a4cb574..59b59dc4e1d 100644
--- a/app/graphql/types/deployment_type.rb
+++ b/app/graphql/types/deployment_type.rb
@@ -11,7 +11,7 @@ module Types
graphql_name 'Deployment'
description 'The deployment of an environment'
- present_using Deployments::DeploymentPresenter
+ present_using ::Deployments::DeploymentPresenter
authorize :read_deployment
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 45357de5502..4e5ddbac8a2 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -231,9 +231,7 @@ module Types
field :work_item_types, Types::WorkItems::TypeType.connection_type,
resolver: Resolvers::WorkItems::TypesResolver,
- description: 'Work item types available to the group.' \
- ' Returns `null` if `work_items` feature flag is disabled.' \
- ' This flag is disabled by default, because the feature is experimental and is subject to change without notice.'
+ description: 'Work item types available to the group.'
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args|
diff --git a/app/graphql/types/incident_management/timeline_event_tag_type.rb b/app/graphql/types/incident_management/timeline_event_tag_type.rb
new file mode 100644
index 00000000000..452294d4797
--- /dev/null
+++ b/app/graphql/types/incident_management/timeline_event_tag_type.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Types
+ module IncidentManagement
+ class TimelineEventTagType < BaseObject
+ graphql_name 'TimelineEventTagType'
+
+ description 'Describes a tag on an incident management timeline event.'
+
+ authorize :read_incident_management_timeline_event_tag
+
+ field :id,
+ Types::GlobalIDType[::IncidentManagement::TimelineEventTag],
+ null: false,
+ description: 'ID of the timeline event tag.'
+
+ field :name,
+ GraphQL::Types::String,
+ null: false,
+ description: 'Name of the timeline event tag.'
+ end
+ end
+end
diff --git a/app/graphql/types/incident_management/timeline_event_type.rb b/app/graphql/types/incident_management/timeline_event_type.rb
index 690facc8732..939dd9f09e5 100644
--- a/app/graphql/types/incident_management/timeline_event_type.rb
+++ b/app/graphql/types/incident_management/timeline_event_type.rb
@@ -53,6 +53,13 @@ module Types
null: false,
description: 'Timestamp when the event occurred.'
+ field :timeline_event_tags,
+ ::Types::IncidentManagement::TimelineEventTagType.connection_type,
+ null: true,
+ description: 'Tags for the incident timeline event.',
+ extras: [:lookahead],
+ resolver: Resolvers::IncidentManagement::TimelineEventTagsResolver
+
field :created_at,
Types::TimeType,
null: false,
diff --git a/app/graphql/types/issue_connection.rb b/app/graphql/types/issue_connection.rb
index 8e5c88648ea..2f07888b43e 100644
--- a/app/graphql/types/issue_connection.rb
+++ b/app/graphql/types/issue_connection.rb
@@ -1,15 +1,22 @@
# frozen_string_literal: true
# Normally this wouldn't be needed and we could use
+#
# type Types::IssueType.connection_type, null: true
-# in a resolver. However we can end up with cyclic definitions,
-# which can result in errors like
+#
+# in a resolver. However we can end up with cyclic definitions.
+# Running the spec locally can result in errors like
+#
# NameError: uninitialized constant Resolvers::GroupIssuesResolver
#
-# Now we would use
+# or other errors. To fix this, we created this file and use
+#
# type "Types::IssueConnection", null: true
+#
# which gives a delayed resolution, and the proper connection type.
+#
# See app/graphql/resolvers/base_issues_resolver.rb
# Reference: https://github.com/rmosolgo/graphql-ruby/issues/3974#issuecomment-1084444214
-
+# and https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#testing-tips-and-tricks
+#
Types::IssueConnection = Types::IssueType.connection_type
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 76fac831199..dd2ad26ce49 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -123,7 +123,15 @@ module Types
field :alert_management_alert,
Types::AlertManagement::AlertType,
null: true,
- description: 'Alert associated to this issue.'
+ description: 'Alert associated to this issue.',
+ deprecated: { reason: 'Use `alert_management_alerts`', milestone: '15.6' }
+
+ field :alert_management_alerts,
+ Types::AlertManagement::AlertType.connection_type,
+ null: true,
+ description: 'Alert Management alerts associated to this issue.',
+ extras: [:lookahead],
+ resolver: Resolvers::AlertManagement::AlertResolver
field :severity, Types::IssuableSeverityEnum, null: true,
description: 'Severity level of the incident.'
diff --git a/app/graphql/types/issue_type_enum.rb b/app/graphql/types/issue_type_enum.rb
index 1044c2ceea4..78cd27f60c3 100644
--- a/app/graphql/types/issue_type_enum.rb
+++ b/app/graphql/types/issue_type_enum.rb
@@ -10,7 +10,11 @@ module Types
end
value 'TASK', value: 'task',
- description: 'Task issue type. Available only when feature flag `work_items` is enabled.',
+ description: 'Task issue type.',
alpha: { milestone: '15.2' }
+
+ value 'OBJECTIVE', value: 'objective',
+ description: 'Objective issue type. Available only when feature flag `okrs_mvc` is enabled.',
+ alpha: { milestone: '15.6' }
end
end
diff --git a/app/graphql/types/issues/unioned_issue_filter_input_type.rb b/app/graphql/types/issues/unioned_issue_filter_input_type.rb
new file mode 100644
index 00000000000..9c7261279c7
--- /dev/null
+++ b/app/graphql/types/issues/unioned_issue_filter_input_type.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ module Issues
+ class UnionedIssueFilterInputType < BaseInputObject
+ graphql_name 'UnionedIssueFilterInput'
+
+ argument :assignee_usernames, [GraphQL::Types::String],
+ required: false,
+ description: 'Filters issues that are assigned to at least one of the given users.'
+ argument :author_usernames, [GraphQL::Types::String],
+ required: false,
+ description: 'Filters issues that are authored by one of the given users.'
+ end
+ end
+end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 8cc600fc68e..49bf7aa638c 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -20,7 +20,7 @@ module Types
description: 'Timestamp of when the merge request was created.'
field :description, GraphQL::Types::String, null: true,
description: 'Description of the merge request (Markdown rendered as HTML for caching).'
- field :diff_head_sha, GraphQL::Types::String, null: true,
+ field :diff_head_sha, GraphQL::Types::String, null: true, calls_gitaly: true,
description: 'Diff head SHA of the merge request.'
field :diff_refs, Types::DiffRefsType, null: true,
description: 'References of the base SHA, the head SHA, and the start SHA for this merge request.'
@@ -100,8 +100,7 @@ module Types
field :detailed_merge_status, ::Types::MergeRequests::DetailedMergeStatusEnum, null: true,
calls_gitaly: true,
- description: 'Detailed merge status of the merge request.',
- alpha: { milestone: '15.3' }
+ description: 'Detailed merge status of the merge request.'
field :mergeable_discussions_state, GraphQL::Types::Boolean, null: true,
calls_gitaly: true,
diff --git a/app/graphql/types/metadata_type.rb b/app/graphql/types/metadata_type.rb
index b00fcfd38ad..492cca365f3 100644
--- a/app/graphql/types/metadata_type.rb
+++ b/app/graphql/types/metadata_type.rb
@@ -6,6 +6,8 @@ module Types
authorize :read_instance_metadata
+ field :enterprise, GraphQL::Types::Boolean, null: false,
+ description: 'Enterprise edition.'
field :kas, ::Types::Metadata::KasType, null: false,
description: 'Metadata about KAS.'
field :revision, GraphQL::Types::String, null: false,
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 5ffc1aeacad..1cbb2ede544 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -47,10 +47,11 @@ module Types
mount_mutation Mutations::DependencyProxy::ImageTtlGroupPolicy::Update
mount_mutation Mutations::DependencyProxy::GroupSettings::Update
mount_mutation Mutations::Environments::CanaryIngress::Update
- mount_mutation Mutations::IncidentManagement::TimelineEvent::Create
+ mount_mutation Mutations::IncidentManagement::TimelineEvent::Create, alpha: { milestone: '15.6' }
mount_mutation Mutations::IncidentManagement::TimelineEvent::PromoteFromNote
mount_mutation Mutations::IncidentManagement::TimelineEvent::Update
mount_mutation Mutations::IncidentManagement::TimelineEvent::Destroy
+ mount_mutation Mutations::IncidentManagement::TimelineEventTag::Create
mount_mutation Mutations::Issues::Create
mount_mutation Mutations::Issues::SetAssignees
mount_mutation Mutations::Issues::SetCrmContacts
@@ -115,6 +116,7 @@ module Types
mount_mutation Mutations::Ci::Pipeline::Destroy
mount_mutation Mutations::Ci::Pipeline::Retry
mount_mutation Mutations::Ci::PipelineSchedule::Delete
+ mount_mutation Mutations::Ci::PipelineSchedule::TakeOwnership
mount_mutation Mutations::Ci::CiCdSettingsUpdate, deprecated: {
reason: :renamed,
replacement: 'ProjectCiCdSettingsUpdate',
diff --git a/app/graphql/types/packages/package_base_type.rb b/app/graphql/types/packages/package_base_type.rb
index 2dc4a2a2bb6..9ec4bb73c47 100644
--- a/app/graphql/types/packages/package_base_type.rb
+++ b/app/graphql/types/packages/package_base_type.rb
@@ -12,6 +12,8 @@ module Types
field :id, ::Types::GlobalIDType[::Packages::Package], null: false, description: 'ID of the package.'
+ field :_links, Types::Packages::PackageLinksType, null: false, method: :itself,
+ description: 'Map of links to perform actions on the package.'
field :can_destroy, GraphQL::Types::Boolean, null: false, description: 'Whether the user can destroy the package.'
field :created_at, Types::TimeType, null: false, description: 'Date of creation.'
field :metadata, Types::Packages::MetadataType,
diff --git a/app/graphql/types/packages/package_links_type.rb b/app/graphql/types/packages/package_links_type.rb
new file mode 100644
index 00000000000..f16937530b9
--- /dev/null
+++ b/app/graphql/types/packages/package_links_type.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Types
+ module Packages
+ class PackageLinksType < BaseObject
+ graphql_name 'PackageLinks'
+ description 'Represents links to perform actions on the package'
+ authorize :read_package
+
+ include ::Routing::PackagesHelper
+
+ field :web_path, GraphQL::Types::String, null: true, description: 'Path to the package details page.'
+
+ def web_path
+ package_path(object)
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/permission_types/ci/runner.rb b/app/graphql/types/permission_types/ci/runner.rb
index 2e92a4011e9..096dcd272cc 100644
--- a/app/graphql/types/permission_types/ci/runner.rb
+++ b/app/graphql/types/permission_types/ci/runner.rb
@@ -6,7 +6,7 @@ module Types
class Runner < BasePermissionType
graphql_name 'RunnerPermissions'
- abilities :read_runner, :update_runner, :delete_runner
+ abilities :read_runner, :update_runner, :delete_runner, :assign_runner
end
end
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index a41af34ef4c..771dad00fb3 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -37,6 +37,10 @@ module Types
null: false,
description: 'Path of the project.'
+ field :incident_management_timeline_event_tags, [Types::IncidentManagement::TimelineEventTagType],
+ null: true,
+ description: 'Timeline event tags for the project.'
+
field :sast_ci_configuration, Types::CiConfiguration::Sast::Type,
null: true,
calls_gitaly: true,
@@ -226,8 +230,7 @@ module Types
Types::IssueType.connection_type,
null: true,
description: 'Issues of the project.',
- extras: [:lookahead],
- resolver: Resolvers::IssuesResolver
+ resolver: Resolvers::ProjectIssuesResolver
field :work_items,
Types::WorkItemType.connection_type,
@@ -241,7 +244,6 @@ module Types
Types::IssueStatusCountsType,
null: true,
description: 'Counts of issues by status for the project.',
- extras: [:lookahead],
resolver: Resolvers::IssueStatusCountsResolver
field :milestones, Types::MilestoneType.connection_type,
@@ -275,7 +277,7 @@ module Types
Types::IssueType,
null: true,
description: 'A single issue of the project.',
- resolver: Resolvers::IssuesResolver.single
+ resolver: Resolvers::ProjectIssuesResolver.single
field :packages,
description: 'Packages of the project.',
@@ -513,9 +515,7 @@ module Types
field :work_item_types, Types::WorkItems::TypeType.connection_type,
resolver: Resolvers::WorkItems::TypesResolver,
- description: 'Work item types available to the project.' \
- ' Returns `null` if `work_items` feature flag is disabled.' \
- ' This flag is disabled by default, because the feature is experimental and is subject to change without notice.'
+ description: 'Work item types available to the project.'
field :timelog_categories, Types::TimeTracking::TimelogCategoryType.connection_type,
null: true,
@@ -532,6 +532,11 @@ module Types
description: "Branch rules configured for the project.",
resolver: Resolvers::Projects::BranchRulesResolver
+ field :languages, [Types::Projects::RepositoryLanguageType],
+ null: true,
+ description: "Programming languages used in the project.",
+ calls_gitaly: true
+
def timelog_categories
object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories)
end
@@ -598,7 +603,7 @@ module Types
end
def sast_ci_configuration
- return unless Ability.allowed?(current_user, :download_code, object)
+ return unless Ability.allowed?(current_user, :read_code, object)
::Security::CiConfiguration::SastParserService.new(object).configuration
end
@@ -609,6 +614,10 @@ module Types
object.service_desk_address
end
+ def languages
+ ::Projects::RepositoryLanguagesService.new(project, current_user).execute
+ end
+
private
def project
diff --git a/app/graphql/types/projects/branch_rule_type.rb b/app/graphql/types/projects/branch_rule_type.rb
index e7632c17cca..1afd2cc3fef 100644
--- a/app/graphql/types/projects/branch_rule_type.rb
+++ b/app/graphql/types/projects/branch_rule_type.rb
@@ -8,6 +8,8 @@ module Types
accepts ::ProtectedBranch
authorize :read_protected_branch
+ alias_method :branch_rule, :object
+
field :name,
type: GraphQL::Types::String,
null: false,
@@ -20,6 +22,12 @@ module Types
calls_gitaly: true,
description: "Check if this branch rule protects the project's default branch."
+ field :matching_branches_count,
+ type: GraphQL::Types::Int,
+ null: false,
+ calls_gitaly: true,
+ description: 'Number of existing branches that match this branch rule.'
+
field :branch_protection,
type: Types::BranchRules::BranchProtectionType,
null: false,
@@ -35,6 +43,10 @@ module Types
Types::TimeType,
null: false,
description: 'Timestamp of when the branch rule was last updated.'
+
+ def matching_branches_count
+ branch_rule.matching(branch_rule.project.repository.branch_names).count
+ end
end
end
end
diff --git a/app/graphql/types/projects/repository_language_type.rb b/app/graphql/types/projects/repository_language_type.rb
new file mode 100644
index 00000000000..76c645c0e85
--- /dev/null
+++ b/app/graphql/types/projects/repository_language_type.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Types
+ module Projects
+ # rubocop: disable Graphql/AuthorizeTypes
+ class RepositoryLanguageType < BaseObject
+ graphql_name 'RepositoryLanguage'
+
+ field :name, GraphQL::Types::String, null: false,
+ description: 'Name of the repository language.'
+
+ field :share, GraphQL::Types::Float, null: true,
+ description: "Percentage of the repository's languages."
+
+ field :color, Types::ColorType, null: true,
+ description: 'Color to visualize the repository language.'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 1b39f43659e..21cb3f9e06c 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -82,6 +82,13 @@ module Types
field :echo, resolver: Resolvers::EchoResolver
+ field :issues,
+ null: true,
+ alpha: { milestone: '15.6' },
+ resolver: Resolvers::IssuesResolver,
+ description: 'Issues visible by the current user.' \
+ ' Returns null if the `root_level_issues_query` feature flag is disabled.'
+
field :issue, Types::IssueType,
null: true,
description: 'Find an issue.' do
@@ -92,7 +99,7 @@ module Types
null: true,
resolver: Resolvers::WorkItemResolver,
alpha: { milestone: '15.1' },
- description: 'Find a work item. Returns `null` if `work_items` feature flag is disabled.'
+ description: 'Find a work item.'
field :merge_request, Types::MergeRequestType,
null: true,
diff --git a/app/graphql/types/release_links_type.rb b/app/graphql/types/release_links_type.rb
index 6bc767152e8..2258adc131c 100644
--- a/app/graphql/types/release_links_type.rb
+++ b/app/graphql/types/release_links_type.rb
@@ -14,12 +14,12 @@ module Types
GraphQL::Types::String,
null: true,
description: 'HTTP URL of the issues page, filtered by this release and `state=closed`.',
- authorize: :download_code
+ authorize: :read_code
field :closed_merge_requests_url,
GraphQL::Types::String,
null: true,
description: 'HTTP URL of the merge request page , filtered by this release and `state=closed`.',
- authorize: :download_code
+ authorize: :read_code
field :edit_url, GraphQL::Types::String, null: true,
description: "HTTP URL of the release's edit page.",
authorize: :update_release
@@ -27,17 +27,17 @@ module Types
GraphQL::Types::String,
null: true,
description: 'HTTP URL of the merge request page , filtered by this release and `state=merged`.',
- authorize: :download_code
+ authorize: :read_code
field :opened_issues_url,
GraphQL::Types::String,
null: true,
description: 'HTTP URL of the issues page, filtered by this release and `state=open`.',
- authorize: :download_code
+ authorize: :read_code
field :opened_merge_requests_url,
GraphQL::Types::String,
null: true,
description: 'HTTP URL of the merge request page, filtered by this release and `state=open`.',
- authorize: :download_code
+ authorize: :read_code
field :self_url, GraphQL::Types::String, null: true,
description: 'HTTP URL of the release.'
end
diff --git a/app/graphql/types/release_source_type.rb b/app/graphql/types/release_source_type.rb
index e05a2926ac1..e1959738c4b 100644
--- a/app/graphql/types/release_source_type.rb
+++ b/app/graphql/types/release_source_type.rb
@@ -5,7 +5,7 @@ module Types
graphql_name 'ReleaseSource'
description 'Represents the source code attached to a release in a particular format'
- authorize :download_code
+ authorize :read_code
field :format, GraphQL::Types::String, null: true,
description: 'Format of the source.'
diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb
index d70fe05c906..a20e53ad1bd 100644
--- a/app/graphql/types/release_type.rb
+++ b/app/graphql/types/release_type.rb
@@ -39,7 +39,7 @@ module Types
description: 'Name of the tag associated with the release.'
field :tag_path, GraphQL::Types::String, null: true,
description: 'Relative web path to the tag associated with the release.',
- authorize: :download_code
+ authorize: :read_code
field :upcoming_release, GraphQL::Types::Boolean, null: true, method: :upcoming_release?,
description: 'Indicates the release is an upcoming release.'
field :historical_release, GraphQL::Types::Boolean, null: true, method: :historical_release?,
diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb
index ba94f59ab6c..ab5d1bd8c9e 100644
--- a/app/graphql/types/repository_type.rb
+++ b/app/graphql/types/repository_type.rb
@@ -4,7 +4,7 @@ module Types
class RepositoryType < BaseObject
graphql_name 'Repository'
- authorize :download_code
+ authorize :read_code
field :blobs, Types::Repository::BlobType.connection_type, null: true, resolver: Resolvers::BlobsResolver, calls_gitaly: true,
description: 'Blobs contained within the repository'
diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb
index 3b8f5c64beb..9d5edec82b2 100644
--- a/app/graphql/types/subscription_type.rb
+++ b/app/graphql/types/subscription_type.rb
@@ -22,6 +22,9 @@ module Types
field :issuable_dates_updated, subscription: Subscriptions::IssuableUpdated, null: true,
description: 'Triggered when the due date or start date of an issuable is updated.'
+ field :issuable_milestone_updated, subscription: Subscriptions::IssuableUpdated, null: true,
+ description: 'Triggered when the milestone of an issuable is updated.'
+
field :merge_request_reviewers_updated,
subscription: Subscriptions::IssuableUpdated,
null: true,
diff --git a/app/graphql/types/work_items/widget_interface.rb b/app/graphql/types/work_items/widget_interface.rb
index a3943361114..b85d0a23535 100644
--- a/app/graphql/types/work_items/widget_interface.rb
+++ b/app/graphql/types/work_items/widget_interface.rb
@@ -16,7 +16,8 @@ module Types
::Types::WorkItems::Widgets::HierarchyType,
::Types::WorkItems::Widgets::LabelsType,
::Types::WorkItems::Widgets::AssigneesType,
- ::Types::WorkItems::Widgets::StartAndDueDateType
+ ::Types::WorkItems::Widgets::StartAndDueDateType,
+ ::Types::WorkItems::Widgets::MilestoneType
].freeze
def self.ce_orphan_types
@@ -38,6 +39,8 @@ module Types
::Types::WorkItems::Widgets::LabelsType
when ::WorkItems::Widgets::StartAndDueDate
::Types::WorkItems::Widgets::StartAndDueDateType
+ when ::WorkItems::Widgets::Milestone
+ ::Types::WorkItems::Widgets::MilestoneType
else
raise "Unknown GraphQL type for widget #{object}"
end
diff --git a/app/graphql/types/work_items/widgets/milestone_input_type.rb b/app/graphql/types/work_items/widgets/milestone_input_type.rb
new file mode 100644
index 00000000000..996c782373f
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/milestone_input_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ module Widgets
+ class MilestoneInputType < BaseInputObject
+ graphql_name 'WorkItemWidgetMilestoneInput'
+
+ argument :milestone_id,
+ Types::GlobalIDType[::Milestone],
+ required: :nullable,
+ prepare: ->(id, _) { id.model_id unless id.nil? },
+ description: 'Milestone to assign to the work item.'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/work_items/widgets/milestone_type.rb b/app/graphql/types/work_items/widgets/milestone_type.rb
new file mode 100644
index 00000000000..73318e58a00
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/milestone_type.rb
@@ -0,0 +1,23 @@
+# 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 MilestoneType < BaseObject
+ graphql_name 'WorkItemWidgetMilestone'
+ description 'Represents a milestone widget'
+
+ implements Types::WorkItems::WidgetInterface
+
+ field :milestone,
+ ::Types::MilestoneType,
+ null: true,
+ description: 'Milestone of the work item.'
+ end
+ # rubocop:enable Graphql/AuthorizeTypes
+ end
+ end
+end
diff --git a/app/graphql/types/x509_certificate_type.rb b/app/graphql/types/x509_certificate_type.rb
new file mode 100644
index 00000000000..806aa441af7
--- /dev/null
+++ b/app/graphql/types/x509_certificate_type.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+# rubocop:disable Graphql/AuthorizeTypes
+
+module Types
+ class X509CertificateType < Types::BaseObject
+ graphql_name 'X509Certificate'
+ description 'Represents an X.509 certificate.'
+
+ field :certificate_status, GraphQL::Types::String,
+ null: false,
+ description: 'Indicates if the certificate is good or revoked.'
+
+ field :created_at, Types::TimeType, null: false,
+ description: 'Timestamp of when the certificate was saved.'
+
+ field :email, GraphQL::Types::String, null: false,
+ description: 'Email associated with the cerificate.'
+
+ field :id, GraphQL::Types::ID, null: false, description: 'ID of the certificate.'
+
+ field :serial_number, GraphQL::Types::String, null: false,
+ description: 'Serial number of the certificate.'
+
+ field :subject, GraphQL::Types::String, null: false, description: 'Subject of the certificate.'
+
+ field :subject_key_identifier, GraphQL::Types::String,
+ null: false,
+ description: 'Subject key identifier of the certificate.'
+
+ field :updated_at, Types::TimeType, null: false,
+ description: 'Timestamp of when the certificate was last updated.'
+
+ field :x509_issuer, Types::X509IssuerType, null: false,
+ description: 'Issuer of the certificate.'
+ end
+end
+
+# rubocop:enable Graphql/AuthorizeTypes
diff --git a/app/graphql/types/x509_issuer_type.rb b/app/graphql/types/x509_issuer_type.rb
new file mode 100644
index 00000000000..a5759e48ee0
--- /dev/null
+++ b/app/graphql/types/x509_issuer_type.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+# rubocop:disable Graphql/AuthorizeTypes
+
+module Types
+ class X509IssuerType < Types::BaseObject
+ graphql_name 'X509Issuer'
+ description 'Issuer of an X.509 certificate.'
+
+ field :created_at, Types::TimeType, null: true,
+ description: 'Timestamp of when the issuer was created.'
+
+ field :crl_url, GraphQL::Types::String, null: true,
+ description: 'Certificate revokation list of the issuer.'
+
+ field :id, GraphQL::Types::ID, null: true, description: 'ID of the issuer.'
+
+ field :subject, GraphQL::Types::String, null: true, description: 'Subject of the issuer.'
+
+ field :subject_key_identifier, GraphQL::Types::String,
+ null: true,
+ description: 'Subject key identifier of the issuer.'
+
+ field :updated_at, Types::TimeType, null: true,
+ description: 'Timestamp of when the issuer was last updated.'
+ end
+end
+
+# rubocop:enable Graphql/AuthorizeTypes
diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb
index 957c2afb6d2..9a323852996 100644
--- a/app/helpers/appearances_helper.rb
+++ b/app/helpers/appearances_helper.rb
@@ -10,7 +10,7 @@ module AppearancesHelper
def default_brand_title
# This resides in a separate method so that EE can easily redefine it.
- 'GitLab Community Edition'
+ _('GitLab Community Edition')
end
def brand_image
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 32af1599bd1..ce6900d1779 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -315,7 +315,7 @@ module ApplicationHelper
class_names << 'epic-boards-page gl-overflow-auto' if current_controller?(:epic_boards)
class_names << 'with-performance-bar' if performance_bar_enabled?
class_names << system_message_class
- class_names << marketing_header_experiment_class
+ class_names << 'logged-out-marketing-header' unless current_user
class_names
end
@@ -377,13 +377,13 @@ module ApplicationHelper
end
def client_class_list
- "gl-browser-#{browser.id} gl-platform-#{browser.platform.id}"
+ "gl-browser-#{browser_id} gl-platform-#{platform_id}"
end
def client_js_flags
{
- "is#{browser.id.to_s.titlecase}": true,
- "is#{browser.platform.id.to_s.titlecase}": true
+ "is#{browser_id.titlecase}": true,
+ "is#{platform_id.titlecase}": true
}
end
@@ -453,20 +453,16 @@ module ApplicationHelper
private
- def appearance
- ::Appearance.current
+ def browser_id
+ browser.unknown? ? 'generic' : browser.id.to_s
end
- def marketing_header_experiment_class
- return if current_user
+ def platform_id
+ browser.platform.unknown? ? 'other' : browser.platform.id.to_s
+ end
- experiment(:logged_out_marketing_header, actor: nil) do |e|
- html_class = 'logged-out-marketing-header-candidate'
- e.candidate { html_class }
- e.variant(:trial_focused) { html_class }
- e.control {}
- e.run
- end
+ def appearance
+ ::Appearance.current
end
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 21b18203677..7f13f609353 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -221,6 +221,7 @@ module ApplicationSettingsHelper
:default_projects_limit,
:default_snippet_visibility,
:delete_inactive_projects,
+ :disable_admin_oauth_scopes,
:disable_feed_token,
:disabled_oauth_sign_in_sources,
:domain_denylist,
@@ -241,6 +242,7 @@ module ApplicationSettingsHelper
:eks_access_key_id,
:eks_secret_access_key,
:email_author_in_body,
+ :email_confirmation_setting,
:enabled_git_access_protocol,
:enforce_terms,
:error_tracking_enabled,
@@ -278,6 +280,7 @@ module ApplicationSettingsHelper
:inactive_projects_send_warning_email_after_months,
:invisible_captcha_enabled,
:jira_connect_application_key,
+ :jira_connect_proxy_url,
:max_artifacts_size,
:max_attachment_size,
:max_export_size,
@@ -543,6 +546,7 @@ module ApplicationSettingsHelper
signup_enabled: @application_setting[:signup_enabled].to_s,
require_admin_approval_after_user_signup: @application_setting[:require_admin_approval_after_user_signup].to_s,
send_user_confirmation_email: @application_setting[:send_user_confirmation_email].to_s,
+ email_confirmation_setting: @application_setting[:email_confirmation_setting].to_s,
minimum_password_length: @application_setting[:minimum_password_length],
minimum_password_length_min: ApplicationSetting::DEFAULT_MINIMUM_PASSWORD_LENGTH,
minimum_password_length_max: Devise.password_length.max,
@@ -558,7 +562,9 @@ 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
+ 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')
}
end
end
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index 617bc0e9bee..798bb7b64a4 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -105,9 +105,10 @@ module AvatarsHelper
end
def avatar_without_link(resource, options = {})
- if resource.is_a?(Namespaces::UserNamespace)
+ case resource
+ when Namespaces::UserNamespace
user_avatar_without_link(options.merge(user: resource.first_owner))
- elsif resource.is_a?(Group)
+ when Group
group_icon(resource, options.merge(class: 'avatar'))
end
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 6c09e15f56f..f08c1a2ff0a 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -74,24 +74,6 @@ module BlobHelper
ref)
end
- def modify_file_button(project = @project, ref = @ref, path = @path, blob:, label:, action:, btn_class:, modal_type:)
- return unless current_user
- return unless blob
-
- common_classes = "btn gl-button btn-default btn-#{btn_class}"
- base_button = button_tag(label, class: "#{common_classes} disabled", disabled: true)
-
- if !on_top_of_branch?(project, ref)
- modify_file_button_tooltip(base_button, _("You can only %{action} files when you are on a branch") % { action: action })
- elsif blob.stored_externally?
- modify_file_button_tooltip(base_button, _("It is not possible to %{action} files that are stored in LFS using the web interface") % { action: action })
- elsif can_modify_blob?(blob, project, ref)
- button_tag label, class: "#{common_classes}", 'data-target' => "#modal-#{modal_type}-blob", 'data-toggle' => 'modal'
- elsif can?(current_user, :fork_project, project) && can?(current_user, :create_merge_request_in, project)
- edit_fork_button_tag(common_classes, project, label, edit_modify_file_fork_params(action), action)
- end
- end
-
def can_modify_blob?(blob, project = @project, ref = @ref)
!blob.stored_externally? && can_edit_tree?(project, ref)
end
@@ -346,12 +328,4 @@ module BlobHelper
@path.to_s.end_with?(Ci::Pipeline::CONFIG_EXTENSION) ||
@path.to_s == @project.ci_config_path_or_default
end
-
- private
-
- def modify_file_button_tooltip(button, tooltip_message)
- # Disabled buttons with tooltips should have the tooltip attached
- # to a wrapper element https://bootstrap-vue.org/docs/components/tooltip#disabled-elements
- content_tag(:span, button, class: 'btn-group has-tooltip', title: tooltip_message, data: { container: 'body' })
- end
end
diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb
index 10cfa97030d..9827f075e54 100644
--- a/app/helpers/broadcast_messages_helper.rb
+++ b/app/helpers/broadcast_messages_helper.rb
@@ -77,11 +77,12 @@ module BroadcastMessagesHelper
return unless current_user.present?
strong_memoize(:current_user_access_level_for_project_or_group) do
- if controller.is_a? Projects::ApplicationController
+ case controller
+ when Projects::ApplicationController
next unless @project
@project.team.max_member_access(current_user.id)
- elsif controller.is_a? Groups::ApplicationController
+ when Groups::ApplicationController
next unless @group
@group.max_member_access_for_user(current_user)
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index 5c3b9d4b5ab..e05adc5cd0e 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -24,7 +24,7 @@ module DiffHelper
end
def show_only_context_commits?
- !!params[:only_context_commits] || @merge_request&.commits&.empty?
+ !!params[:only_context_commits] || @merge_request.has_no_commits?
end
def diff_options
@@ -109,11 +109,11 @@ module DiffHelper
end
def inline_diff_btn
- diff_btn('Inline', 'inline', diff_view == :inline)
+ diff_btn(s_('Diffs|Inline'), 'inline', diff_view == :inline)
end
def parallel_diff_btn
- diff_btn('Side-by-side', 'parallel', diff_view == :parallel)
+ diff_btn(s_('Diffs|Side-by-side'), 'parallel', diff_view == :parallel)
end
def submodule_link(blob, ref, repository = @repository)
@@ -227,7 +227,6 @@ module DiffHelper
end
def conflicts(allow_tree_conflicts: false)
- return unless options[:merge_ref_head_diff]
return unless merge_request.cannot_be_merged?
conflicts_service = MergeRequests::Conflicts::ListService.new(merge_request, allow_tree_conflicts: allow_tree_conflicts) # rubocop:disable CodeReuse/ServiceClass
@@ -244,6 +243,10 @@ module DiffHelper
{}
end
+ def params_with_whitespace
+ hide_whitespace? ? safe_params.except(:w) : safe_params.merge(w: 1)
+ end
+
private
def diff_btn(title, name, selected)
@@ -277,13 +280,10 @@ module DiffHelper
params[:w] == '1'
end
- def params_with_whitespace
- hide_whitespace? ? request.query_parameters.except(:w) : request.query_parameters.merge(w: 1)
- end
-
def toggle_whitespace_link(url, options)
options[:class] = [*options[:class], 'btn gl-button btn-default'].join(' ')
- link_to "#{hide_whitespace? ? 'Show' : 'Hide'} whitespace changes", url, class: options[:class]
+ toggle_text = hide_whitespace? ? s_('Diffs|Show whitespace changes') : s_('Diffs|Hide whitespace changes')
+ link_to toggle_text, url, class: options[:class]
end
def code_navigation_path(diffs)
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index b717cbcc312..087e4838ed9 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -68,7 +68,7 @@ module EventsHelper
author = event.author
if author
- name = self_added ? 'You' : author.name
+ name = self_added ? _('You') : author.name
link_to name, user_path(author.username), title: name
else
escape_once(event.author_name)
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index 9e42aeea9ce..963f0b7afc4 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -40,7 +40,7 @@ module FormHelper
end
def dropdown_max_select(data, feature_flag)
- return data[:'max-select'] unless Feature.enabled?(feature_flag)
+ return data[:'max-select'] unless feature_flag.nil? || Feature.enabled?(feature_flag)
if data[:'max-select'] && data[:'max-select'] < ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS
data[:'max-select']
@@ -162,12 +162,7 @@ module FormHelper
new_options[:title] = _('Select assignee(s)')
new_options[:data][:'dropdown-header'] = 'Assignee(s)'
-
- if Feature.enabled?(:limit_assignees_per_issuable)
- new_options[:data][:'max-select'] = ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS
- else
- new_options[:data].delete(:'max-select')
- end
+ new_options[:data][:'max-select'] = ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS
new_options
end
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index 1be395437ea..178e9d0ab74 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -17,6 +17,7 @@ module GitlabRoutingHelper
include ::Routing::WikiHelper
include ::Routing::GraphqlHelper
include ::Routing::PseudonymizationHelper
+ include ::Routing::PackagesHelper
included do
Gitlab::Routing.includes_helpers(self)
end
diff --git a/app/helpers/graph_helper.rb b/app/helpers/graph_helper.rb
index 45ca820f7b3..788002f126d 100644
--- a/app/helpers/graph_helper.rb
+++ b/app/helpers/graph_helper.rb
@@ -5,8 +5,10 @@ module GraphHelper
refs = [commit.ref_names(repo).join(' ')]
# append note count
- notes_count = @graph.notes[commit.id]
- refs << "[#{pluralize(notes_count, 'note')}]" if notes_count > 0
+ unless Feature.enabled?(:disable_network_graph_notes_count, @project, type: :experiment)
+ notes_count = @graph.notes[commit.id]
+ refs << "[#{pluralize(notes_count, 'note')}]" if notes_count > 0
+ end
refs.join
end
diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb
index 6a013a6c864..5034a4cb9b4 100644
--- a/app/helpers/groups/group_members_helper.rb
+++ b/app/helpers/groups/group_members_helper.rb
@@ -5,10 +5,6 @@ module Groups::GroupMembersHelper
AVATAR_SIZE = 40
- def group_member_select_options
- { multiple: true, class: 'input-clamp qa-member-select-field ', scope: :all, email_user: true }
- end
-
def group_members_app_data(group, members:, invited:, access_requests:, banned:, include_relations:, search:)
{
user: group_members_list_data(group, members, { param_name: :page, params: { invited_members_page: nil, search_invited: nil } }),
@@ -16,7 +12,8 @@ module Groups::GroupMembersHelper
invite: group_members_list_data(group, invited.nil? ? [] : invited, { param_name: :invited_members_page, params: { page: nil } }),
access_request: group_members_list_data(group, access_requests.nil? ? [] : access_requests),
source_id: group.id,
- can_manage_members: can?(current_user, :admin_group_member, group)
+ can_manage_members: can?(current_user, :admin_group_member, group),
+ can_manage_access_requests: can?(current_user, :admin_member_access_request, group)
}
end
diff --git a/app/helpers/groups/observability_helper.rb b/app/helpers/groups/observability_helper.rb
new file mode 100644
index 00000000000..6fb6acce386
--- /dev/null
+++ b/app/helpers/groups/observability_helper.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Groups
+ module ObservabilityHelper
+ ACTION_TO_PATH = {
+ 'dashboards' => {
+ path: '/',
+ title: -> { s_('Dashboards') }
+ },
+ 'manage' => {
+ path: '/dashboards',
+ title: -> { s_('Manage Dashboards') }
+ },
+ 'explore' => {
+ path: '/explore',
+ title: -> { s_('Explore') }
+ }
+ }.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}"
+ end
+
+ def observability_page_title
+ observability_config_for(params).fetch(:title).call
+ end
+
+ 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
+ end
+end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 6b00c213875..e8fc6bc292f 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -112,16 +112,6 @@ module GroupsHelper
s_("GroupSettings|Available only on the top-level group. Applies to all subgroups. Groups already shared with a group outside %{group} are still shared unless removed manually.").html_safe % { group: link_to_group(group) }
end
- def parent_group_options(current_group)
- exclude_groups = current_group.self_and_descendants.pluck_primary_key
- exclude_groups << current_group.parent_id if current_group.parent_id
- groups = GroupsFinder.new(current_user, min_access_level: Gitlab::Access::OWNER, exclude_group_ids: exclude_groups).execute.sort_by(&:human_name).map do |group|
- { id: group.id, text: group.human_name }
- end
-
- groups.to_json
- end
-
def render_setting_to_allow_project_access_token_creation?(group)
group.root? && current_user.can?(:admin_setting_to_allow_project_access_token_creation, group)
end
@@ -158,8 +148,13 @@ module GroupsHelper
}
end
- def subgroups_and_projects_list_app_data(group)
+ def group_overview_tabs_app_data(group)
{
+ subgroups_and_projects_endpoint: group_children_path(group, format: :json),
+ shared_projects_endpoint: group_shared_projects_path(group, format: :json),
+ archived_projects_endpoint: group_children_path(group, format: :json, archived: 'only'),
+ current_group_visibility: group.visibility,
+ initial_sort: project_list_sort_by,
show_schema_markup: 'true',
new_subgroup_path: new_group_path(parent_id: group.id, anchor: 'create-group-pane'),
new_project_path: new_project_path(namespace_id: group.id),
@@ -172,16 +167,6 @@ module GroupsHelper
}
end
- def group_overview_tabs_app_data(group)
- {
- subgroups_and_projects_endpoint: group_children_path(group, format: :json),
- shared_projects_endpoint: group_shared_projects_path(group, format: :json),
- archived_projects_endpoint: group_children_path(group, format: :json, archived: 'only'),
- current_group_visibility: group.visibility,
- initial_sort: project_list_sort_by
- }.merge(subgroups_and_projects_list_app_data(group))
- end
-
def enabled_git_access_protocol_options_for_group
case ::Gitlab::CurrentSettings.enabled_git_access_protocol
when nil, ""
diff --git a/app/helpers/hooks_helper.rb b/app/helpers/hooks_helper.rb
index e050ccc0e40..921e30edbaa 100644
--- a/app/helpers/hooks_helper.rb
+++ b/app/helpers/hooks_helper.rb
@@ -4,7 +4,7 @@ module HooksHelper
def webhook_form_data(hook)
{
url: hook.url,
- url_variables: nil
+ url_variables: Gitlab::Json.dump(hook.url_variables.keys.map { { key: _1 } })
}
end
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index 6f7ac069fe4..c81041c2d9c 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -42,7 +42,7 @@ module IconsHelper
content_tag(
:svg,
- content_tag(:use, '', { 'href' => "#{sprite_icon_path}##{icon_name}" } ),
+ content_tag(:use, '', { 'href' => "#{sprite_icon_path}##{icon_name}" }),
class: css_classes.empty? ? nil : css_classes.join(' '),
data: { testid: "#{icon_name}-icon" }
)
@@ -70,18 +70,14 @@ module IconsHelper
# gl_loading_icon(css_class: "foo-bar")
#
# See also https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/base-loading-icon--default
- def gl_loading_icon(inline: false, color: 'dark', size: 'sm', css_class: nil)
- spinner = content_tag(:span, "", {
- class: %[gl-spinner gl-spinner-#{color} gl-spinner-#{size} gl-vertical-align-text-bottom!],
- aria: { label: _('Loading') }
- })
-
- container_classes = ['gl-spinner-container']
- container_classes << css_class unless css_class.blank?
- content_tag(inline ? :span : :div, spinner, {
- class: container_classes,
- role: 'status'
- })
+ def gl_loading_icon(inline: false, color: 'dark', size: 'sm', css_class: nil, data: nil)
+ render Pajamas::SpinnerComponent.new(
+ inline: inline,
+ color: color,
+ size: size,
+ class: css_class,
+ data: data
+ )
end
def external_snippet_icon(name)
diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb
index 5b3ca25b5af..34f4749c42a 100644
--- a/app/helpers/ide_helper.rb
+++ b/app/helpers/ide_helper.rb
@@ -5,6 +5,7 @@ module IdeHelper
{
'can-use-new-web-ide' => can_use_new_web_ide?.to_s,
'use-new-web-ide' => use_new_web_ide?.to_s,
+ 'new-web-ide-help-page-path' => help_page_path('user/project/web_ide/index.md', anchor: 'vscode-reimplementation'),
'user-preferences-path' => profile_preferences_path,
'branch-name' => @branch
}.merge(use_new_web_ide? ? new_ide_data : legacy_ide_data)
@@ -33,6 +34,7 @@ module IdeHelper
'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'),
+ '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'),
'web-ide-help-page-path' => help_page_path('user/project/web_ide/index.md'),
diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb
index a1512d40235..abfa55cff24 100644
--- a/app/helpers/integrations_helper.rb
+++ b/app/helpers/integrations_helper.rb
@@ -160,6 +160,31 @@ module IntegrationsHelper
!Gitlab.com?
end
+ def integration_issue_type(issue_type)
+ issue_type_i18n_map = {
+ 'issue' => _('Issue'),
+ 'incident' => _('Incident'),
+ 'test_case' => _('Test case'),
+ 'requirement' => _('Requirement'),
+ 'task' => _('Task')
+ }
+
+ issue_type_i18n_map[issue_type] || issue_type
+ end
+
+ def integration_todo_target_type(target_type)
+ target_type_i18n_map = {
+ 'Commit' => _('Commit'),
+ 'Issue' => _('Issue'),
+ 'MergeRequest' => _('Merge Request'),
+ 'Epic' => _('Epic'),
+ DesignManagement::Design.name => _('design'),
+ AlertManagement::Alert.name => _('alert')
+ }
+
+ target_type_i18n_map[target_type] || target_type
+ end
+
extend self
private
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 2804a58da9e..fd181109a94 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -135,17 +135,6 @@ module IssuablesHelper
end
# rubocop: enable CodeReuse/ActiveRecord
- def milestone_dropdown_label(milestone_title, default_label = _('Milestone'))
- title =
- case milestone_title
- when Milestone::Upcoming.name then Milestone::Upcoming.title
- when Milestone::Started.name then Milestone::Started.title
- else milestone_title.presence
- end
-
- h(title || default_label)
- end
-
def issuable_meta_author_status(author)
return "" unless author&.status&.customized? && status = user_status(author)
@@ -157,9 +146,9 @@ module IssuablesHelper
if issuable.respond_to?(:work_item_type) && WorkItems::Type::WI_TYPES_WITH_CREATED_HEADER.include?(issuable.work_item_type.base_type)
output << content_tag(:span, sprite_icon("#{issuable.work_item_type.icon_name}", 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: issuable.issue_type.capitalize, created_at: time_ago_with_tooltip(issuable.created_at) }, class: 'gl-mr-2' )
+ output << content_tag(:span, s_('IssuableStatus|%{wi_type} created %{created_at} by ').html_safe % { wi_type: issuable.issue_type.capitalize, created_at: time_ago_with_tooltip(issuable.created_at) }, class: 'gl-mr-2')
else
- output << content_tag(:span, s_('IssuableStatus|Created %{created_at} by').html_safe % { created_at: time_ago_with_tooltip(issuable.created_at) }, class: 'gl-mr-2' )
+ output << content_tag(:span, s_('IssuableStatus|Created %{created_at} by').html_safe % { created_at: time_ago_with_tooltip(issuable.created_at) }, class: 'gl-mr-2')
end
if issuable.is_a?(Issue) && issuable.service_desk_reply_to
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 115cdd432e3..932a50d9451 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -10,7 +10,6 @@ module IssuesHelper
def issue_css_classes(issue)
classes = ["issue"]
classes << "closed" if issue.closed?
- classes << "today" if issue.new?
classes << "gl-cursor-grab" if @sort == 'relative_position'
classes.join(' ')
end
@@ -108,9 +107,10 @@ module IssuesHelper
def awards_sort(awards)
awards.sort_by do |award, award_emojis|
- if award == "thumbsup"
+ case award
+ when "thumbsup"
0
- elsif award == "thumbsdown"
+ when "thumbsdown"
1
else
2
diff --git a/app/helpers/json_helper.rb b/app/helpers/json_helper.rb
new file mode 100644
index 00000000000..e61c789fd08
--- /dev/null
+++ b/app/helpers/json_helper.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module JsonHelper
+ # These two JSON helpers are short-form wrappers for the Gitlab::Json
+ # class, which should be used in place of .to_json calls or calls to
+ # the JSON class.
+ def json_generate(*args)
+ Gitlab::Json.generate(*args)
+ end
+
+ def json_parse(*args)
+ Gitlab::Json.parse(*args)
+ end
+end
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index 866399f3021..9baea43b77d 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -6,12 +6,6 @@ module MarkupHelper
include ActionView::Helpers::TextHelper
include ActionView::Context
- # Let's increase the render timeout
- # For a smaller one, a test that renders the blob content statically fails
- # We can consider removing this custom timeout when markup_rendering_timeout FF is removed:
- # https://gitlab.com/gitlab-org/gitlab/-/issues/365358
- RENDER_TIMEOUT = 5.seconds
-
# Use this in places where you would normally use link_to(gfm(...), ...).
def link_to_markdown(body, url, html_options = {})
return '' if body.blank?
@@ -97,8 +91,9 @@ module MarkupHelper
context[:project] ||= @project
context[:group] ||= @group
- html = markdown_unsafe(text, context)
- prepare_for_rendering(html, context)
+ html = Markup::RenderingService.new(text, context: context, postprocess_context: postprocess_context).execute
+
+ Hamlit::RailsHelpers.preserve(html)
end
def markdown_field(object, field, context = {})
@@ -114,8 +109,13 @@ module MarkupHelper
def markup(file_name, text, context = {})
context[:project] ||= @project
context[:text_source] ||= :blob
- html = context.delete(:rendered) || markup_unsafe(file_name, text, context)
- prepare_for_rendering(html, context)
+ prepare_asciidoc_context(file_name, context)
+
+ html = Markup::RenderingService
+ .new(text, file_name: file_name, context: context, postprocess_context: postprocess_context)
+ .execute
+
+ Hamlit::RailsHelpers.preserve(html)
end
def render_wiki_content(wiki_page, context = {})
@@ -123,35 +123,13 @@ module MarkupHelper
return '' unless text.present?
context = render_wiki_content_context(wiki_page.wiki, wiki_page, context)
- html = markup_unsafe(wiki_page.path, text, context)
-
- prepare_for_rendering(html, context)
- end
-
- def markup_unsafe(file_name, text, context = {})
- return '' unless text.present?
+ prepare_asciidoc_context(wiki_page.path, context)
- markup = proc do
- if Gitlab::MarkupHelper.gitlab_markdown?(file_name)
- markdown_unsafe(text, context)
- elsif Gitlab::MarkupHelper.asciidoc?(file_name)
- asciidoc_unsafe(text, context)
- elsif Gitlab::MarkupHelper.plain?(file_name)
- plain_unsafe(text)
- else
- other_markup_unsafe(file_name, text, context)
- end
- end
-
- if Feature.enabled?(:markup_rendering_timeout, @project)
- Gitlab::RenderTimeout.timeout(foreground: RENDER_TIMEOUT, &markup)
- else
- markup.call
- end
- rescue StandardError => e
- Gitlab::ErrorTracking.track_exception(e, project_id: @project&.id, file_name: file_name)
+ html = Markup::RenderingService
+ .new(text, file_name: wiki_page.path, context: context, postprocess_context: postprocess_context)
+ .execute
- simple_format(text)
+ Hamlit::RailsHelpers.preserve(html)
end
# Returns the text necessary to reference `entity` across projects
@@ -214,29 +192,6 @@ module MarkupHelper
end
end
- def markdown_unsafe(text, context = {})
- Banzai.render(text, context)
- end
-
- def asciidoc_unsafe(text, context = {})
- context.reverse_merge!(
- commit: @commit,
- ref: @ref,
- requested_path: @path
- )
- Gitlab::Asciidoc.render(text, context)
- end
-
- def plain_unsafe(text)
- content_tag :pre, class: 'plain-readme' do
- text
- end
- end
-
- def other_markup_unsafe(file_name, text, context = {})
- Gitlab::OtherMarkup.render(file_name, text, context)
- end
-
def render_markdown_field(object, field, context = {})
post_process = context.delete(:post_process)
post_process = true if post_process.nil?
@@ -257,7 +212,15 @@ module MarkupHelper
def prepare_for_rendering(html, context = {})
return '' unless html.present?
- context.reverse_merge!(
+ context.reverse_merge!(postprocess_context)
+
+ html = Banzai.post_process(html, context)
+
+ Hamlit::RailsHelpers.preserve(html)
+ end
+
+ def postprocess_context
+ {
current_user: (current_user if defined?(current_user)),
# RepositoryLinkFilter and UploadLinkFilter
@@ -265,11 +228,13 @@ module MarkupHelper
wiki: @wiki,
ref: @ref,
requested_path: @path
- )
+ }
+ end
- html = Banzai.post_process(html, context)
+ def prepare_asciidoc_context(file_name, context)
+ return unless Gitlab::MarkupHelper.asciidoc?(file_name)
- Hamlit::RailsHelpers.preserve(html)
+ context.reverse_merge!(commit: @commit, ref: @ref, requested_path: @path)
end
extend self
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 45ded6e35d8..1d7d812dc5d 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -242,13 +242,13 @@ module MergeRequestsHelper
''
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-mb-n2'
+ 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'
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', avatar: false)
+ 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-mb-n2'
+ 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
diff --git a/app/helpers/nav/top_nav_helper.rb b/app/helpers/nav/top_nav_helper.rb
index 32d3f4aebb4..751900f4593 100644
--- a/app/helpers/nav/top_nav_helper.rb
+++ b/app/helpers/nav/top_nav_helper.rb
@@ -281,76 +281,28 @@ module Nav
end
def projects_submenu_items(builder:)
- if Feature.enabled?(:remove_extra_primary_submenu_options)
- title = _('View all projects')
-
- builder.add_primary_menu_item(
- id: 'your',
- title: title,
- href: dashboard_projects_path,
- data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) }
- )
- else
- # These project links come from `app/views/layouts/nav/projects_dropdown/_show.html.haml`
- [
- { id: 'your', title: _('Your projects'), href: dashboard_projects_path },
- { id: 'starred', title: _('Starred projects'), href: starred_dashboard_projects_path },
- { id: 'explore', title: _('Explore projects'), href: explore_root_path },
- { id: 'topics', title: _('Explore topics'), href: topics_explore_projects_path }
- ].each do |item|
- builder.add_primary_menu_item(
- **item,
- data: { qa_selector: 'menu_item_link', qa_title: item[:title], **menu_data_tracking_attrs(item[:title]) }
- )
- end
-
- title = _('Create new project')
-
- builder.add_secondary_menu_item(
- id: 'create',
- title: title,
- href: new_project_path,
- data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) }
- )
- end
+ title = _('View all projects')
+
+ builder.add_primary_menu_item(
+ id: 'your',
+ title: title,
+ href: dashboard_projects_path,
+ data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) }
+ )
end
def groups_submenu
# These group links come from `app/views/layouts/nav/groups_dropdown/_show.html.haml`
builder = ::Gitlab::Nav::TopNavMenuBuilder.new
- if Feature.enabled?(:remove_extra_primary_submenu_options)
- title = _('View all groups')
-
- builder.add_primary_menu_item(
- id: 'your',
- title: title,
- href: dashboard_groups_path,
- data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) }
- )
- else
- [
- { id: 'your', title: _('Your groups'), href: dashboard_groups_path },
- { id: 'explore', title: _('Explore groups'), href: explore_groups_path }
- ].each do |item|
- builder.add_primary_menu_item(
- **item,
- data: { qa_selector: 'menu_item_link', qa_title: item[:title], **menu_data_tracking_attrs(item[:title]) }
- )
- end
-
- if current_user.can_create_group?
- title = _('Create group')
-
- builder.add_secondary_menu_item(
- id: 'create',
- title: title,
- href: new_group_path,
- data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) }
- )
- end
- end
+ title = _('View all groups')
+ builder.add_primary_menu_item(
+ id: 'your',
+ title: title,
+ href: dashboard_groups_path,
+ data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) }
+ )
builder.build
end
end
diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb
index f21538fd3fb..50c7db683c6 100644
--- a/app/helpers/projects/alert_management_helper.rb
+++ b/app/helpers/projects/alert_management_helper.rb
@@ -21,6 +21,7 @@ module Projects::AlertManagementHelper
'project-path' => project.full_path,
'project-id' => project.id,
'project-issues-path' => project_issues_path(project),
+ 'project-alert-management-details-path' => details_project_alert_management_path(project, alert_id),
'page' => 'OPERATIONS',
'can-update' => can?(current_user, :update_alert_management_alert, project).to_s
}
diff --git a/app/helpers/projects/ml/experiments_helper.rb b/app/helpers/projects/ml/experiments_helper.rb
new file mode 100644
index 00000000000..29bd879859e
--- /dev/null
+++ b/app/helpers/projects/ml/experiments_helper.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+module Projects
+ module Ml
+ module ExperimentsHelper
+ require 'json'
+ include ActionView::Helpers::NumberHelper
+
+ def candidates_table_items(candidates)
+ items = candidates.map do |candidate|
+ {
+ **candidate.params.to_h { |p| [p.name, p.value] },
+ **candidate.latest_metrics.to_h { |m| [m.name, number_with_precision(m.value, precision: 4)] }
+ }
+ end
+
+ Gitlab::Json.generate(items)
+ end
+
+ def unique_logged_names(candidates, &selector)
+ Gitlab::Json.generate(candidates.flat_map(&selector).map(&:name).uniq)
+ end
+ end
+ end
+end
diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb
index c72beb4d722..edbdb9d4adf 100644
--- a/app/helpers/projects/pipeline_helper.rb
+++ b/app/helpers/projects/pipeline_helper.rb
@@ -19,6 +19,7 @@ module Projects
blob_path: project_blob_path(project, pipeline.sha),
has_test_report: pipeline.has_test_reports?,
empty_state_image_path: image_path('illustrations/empty-state/empty-test-cases-lg.svg'),
+ empty_dag_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'),
artifacts_expired_image_path: image_path('illustrations/pipeline.svg'),
tests_count: pipeline.test_report_summary.total[:count]
}
diff --git a/app/helpers/projects/project_members_helper.rb b/app/helpers/projects/project_members_helper.rb
index 51a7d3e35d0..6026124abb9 100644
--- a/app/helpers/projects/project_members_helper.rb
+++ b/app/helpers/projects/project_members_helper.rb
@@ -8,7 +8,8 @@ module Projects::ProjectMembersHelper
invite: project_members_list_data(project, invited.nil? ? [] : invited),
access_request: project_members_list_data(project, access_requests.nil? ? [] : access_requests),
source_id: project.id,
- can_manage_members: Ability.allowed?(current_user, :admin_project_member, project)
+ can_manage_members: Ability.allowed?(current_user, :admin_project_member, project),
+ can_manage_access_requests: Ability.allowed?(current_user, :admin_member_access_request, project)
}.to_json
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index cddcdf77710..e41a3fa5091 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -69,7 +69,7 @@ module ProjectsHelper
if opts[:name]
inject_classes.concat(["js-user-link", opts[:mobile_classes]])
else
- inject_classes.append( "has-tooltip" )
+ inject_classes.append("has-tooltip")
end
inject_classes = inject_classes.compact.join(" ")
@@ -393,7 +393,8 @@ module ProjectsHelper
membersPagePath: project_project_members_path(project),
environmentsHelpPath: help_page_path('ci/environments/index'),
featureFlagsHelpPath: help_page_path('operations/feature_flags'),
- releasesHelpPath: help_page_path('user/project/releases/index')
+ releasesHelpPath: help_page_path('user/project/releases/index'),
+ infrastructureHelpPath: help_page_path('user/infrastructure/index')
}
end
@@ -475,6 +476,10 @@ module ProjectsHelper
localized_access_names[access] || Gitlab::Access.human_access(access)
end
+ def badge_count(number)
+ format_cached_count(1000, number)
+ end
+
private
def localized_access_names
@@ -662,7 +667,8 @@ module ProjectsHelper
containerRegistryAccessLevel: feature.container_registry_access_level,
environmentsAccessLevel: feature.environments_access_level,
featureFlagsAccessLevel: feature.feature_flags_access_level,
- releasesAccessLevel: feature.releases_access_level
+ releasesAccessLevel: feature.releases_access_level,
+ infrastructureAccessLevel: feature.infrastructure_access_level
}
end
diff --git a/app/helpers/recaptcha_helper.rb b/app/helpers/recaptcha_helper.rb
index 59f0dc8f819..b6b75d03b2e 100644
--- a/app/helpers/recaptcha_helper.rb
+++ b/app/helpers/recaptcha_helper.rb
@@ -2,27 +2,17 @@
module RecaptchaHelper
def recaptcha_enabled?
- return false if gitlab_qa?
+ return false if Gitlab::Qa.request?(request.user_agent)
!!Gitlab::Recaptcha.enabled?
end
alias_method :show_recaptcha_sign_up?, :recaptcha_enabled?
def recaptcha_enabled_on_login?
- return false if gitlab_qa?
+ return false if Gitlab::Qa.request?(request.user_agent)
Gitlab::Recaptcha.enabled_on_login?
end
-
- private
-
- def gitlab_qa?
- return false unless Gitlab.com?
- return false unless request.user_agent.present?
- return false unless Gitlab::Environment.qa_user_agent.present?
-
- ActiveSupport::SecurityUtils.secure_compare(request.user_agent, Gitlab::Environment.qa_user_agent)
- end
end
RecaptchaHelper.prepend_mod
diff --git a/app/helpers/reminder_emails_helper.rb b/app/helpers/reminder_emails_helper.rb
index 132fc3b784c..e46d9273100 100644
--- a/app/helpers/reminder_emails_helper.rb
+++ b/app/helpers/reminder_emails_helper.rb
@@ -41,7 +41,7 @@ module ReminderEmailsHelper
body = invitation_reminder_body_text(reminder_index)
- (format == :html ? html_escape(body) : body ) % options
+ (format == :html ? html_escape(body) : body) % options
end
def invitation_reminder_accept_link(token, format: nil)
diff --git a/app/helpers/routing/packages_helper.rb b/app/helpers/routing/packages_helper.rb
new file mode 100644
index 00000000000..4e76be3b5a3
--- /dev/null
+++ b/app/helpers/routing/packages_helper.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Routing
+ module PackagesHelper
+ def package_path(package, **options)
+ Gitlab::UrlBuilder.build(package, only_path: true, **options)
+ end
+ end
+end
diff --git a/app/helpers/routing/projects_helper.rb b/app/helpers/routing/projects_helper.rb
index 8c0bd9b1ecc..f4732e398f0 100644
--- a/app/helpers/routing/projects_helper.rb
+++ b/app/helpers/routing/projects_helper.rb
@@ -43,7 +43,14 @@ module Routing
end
def work_item_url(entity, *args)
- project_work_items_url(entity.project, entity.id, *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
+
+ project_work_items_url(entity.project, entity.iid, **options)
end
def merge_request_url(entity, *args)
@@ -89,7 +96,9 @@ module Routing
private
def use_work_items_path?(issue)
- issue.issue_type == 'task' && issue.project.work_items_feature_flag_enabled?
+ issue.issue_type == 'task'
end
end
end
+
+Routing::ProjectsHelper.prepend_mod
diff --git a/app/helpers/routing/pseudonymization_helper.rb b/app/helpers/routing/pseudonymization_helper.rb
index eb4e5d1c01c..dce0517690d 100644
--- a/app/helpers/routing/pseudonymization_helper.rb
+++ b/app/helpers/routing/pseudonymization_helper.rb
@@ -43,11 +43,12 @@ module Routing
private
def mask_id(value)
- if @request.path_parameters[:controller] == 'projects/blob'
+ case @request.path_parameters[:controller]
+ when 'projects/blob'
':repository_path'
- elsif @request.path_parameters[:controller] == 'projects'
+ when 'projects'
"project#{@project&.id}"
- elsif @request.path_parameters[:controller] == 'groups'
+ when 'groups'
"namespace#{@group&.id}"
else
value
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index f2b88287277..b8ac2afa7d6 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -181,20 +181,51 @@ module SearchHelper
options
end
- # search_context exposes a bit too much data to the frontend, this controls what data we share and when.
+ def search_group
+ # group gets derived from the Project in the project's scope
+ @group || @project&.group
+ end
+
+ def search_has_group?
+ search_group&.present? && search_group&.persisted?
+ end
+
+ def search_has_project?
+ @project&.present? && @project&.persisted?
+ end
+
def header_search_context
{}.tap do |hash|
- hash[:group] = { id: search_context.group.id, name: search_context.group.name, full_name: search_context.group.full_name } if search_context.for_group?
- hash[:group_metadata] = search_context.group_metadata if search_context.for_group?
+ if search_has_group?
+ hash[:group] = { id: search_group.id, name: search_group.name, full_name: search_group.full_name }
+ hash[:group_metadata] = { issues_path: issues_group_path(search_group), mr_path: merge_requests_group_path(search_group) }
+ end
- hash[:project] = { id: search_context.project.id, name: search_context.project.name } if search_context.for_project?
- hash[:project_metadata] = search_context.project_metadata if search_context.for_project?
+ 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[:code_search] = search_scope.nil?
+ hash[:ref] = @ref if @ref && can?(current_user, :read_code, @project)
+ end
- hash[:scope] = search_context.scope if search_context.for_project? || search_context.for_group?
- hash[:code_search] = search_context.code_search? if search_context.for_project? || search_context.for_group?
+ hash[:scope] = search_scope if search_has_project? || search_has_group?
+ hash[:for_snippets] = @snippet&.present? || @snippets&.any?
+ end
+ end
- hash[:ref] = search_context.ref if can?(current_user, :download_code, search_context.project)
- hash[:for_snippets] = search_context.for_snippets?
+ def search_scope
+ if current_controller?(:issues)
+ 'issues'
+ elsif current_controller?(:merge_requests)
+ 'merge_requests'
+ elsif current_controller?(:wikis)
+ 'wiki_blobs'
+ elsif current_controller?(:commits)
+ 'commits'
+ elsif current_controller?(:groups)
+ if %w(issues merge_requests).include?(controller.action_name)
+ controller.action_name
+ end
end
end
@@ -237,7 +268,7 @@ module SearchHelper
result = []
- if can?(current_user, :download_code, @project)
+ if can?(current_user, :read_code, @project)
result.concat([
{ category: "In this project", label: _("Files"), url: project_tree_path(@project, ref) },
{ category: "In this project", label: _("Commits"), url: project_commits_path(@project, ref) }
@@ -386,7 +417,11 @@ module SearchHelper
active_scope = @scope == scope_name
result = { label: label, scope: scope_name, data: data, link: search_path(search_params), active: active_scope }
- result[:count] = @search_results.formatted_count(scope_name) if active_scope && !@timeout
+
+ if active_scope
+ result[:count] = !@timeout ? @search_results.formatted_count(scope_name) : "0"
+ end
+
result[:count_link] = search_count_path(search_params) unless active_scope
result
@@ -395,21 +430,24 @@ module SearchHelper
# search page scope navigation
def search_navigation
{
- projects: { label: _("Projects"), data: { qa_selector: 'projects_tab' }, condition: @project.nil? },
- blobs: { label: _("Code"), data: { qa_selector: 'code_tab' }, condition: project_search_tabs?(:blobs) || search_service.show_elasticsearch_tabs? || feature_flag_tab_enabled?(:global_search_code_tab) },
- issues: { label: _("Issues"), condition: project_search_tabs?(:issues) || feature_flag_tab_enabled?(:global_search_issues_tab) },
- merge_requests: { label: _("Merge requests"), condition: project_search_tabs?(:merge_requests) || feature_flag_tab_enabled?(:global_search_merge_requests_tab) },
- wiki_blobs: { label: _("Wiki"), condition: project_search_tabs?(:wiki) || search_service.show_elasticsearch_tabs? },
- commits: { label: _("Commits"), condition: project_search_tabs?(:commits) || (search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_commits_tab)) },
- notes: { label: _("Comments"), condition: project_search_tabs?(:notes) || search_service.show_elasticsearch_tabs? },
- milestones: { label: _("Milestones"), condition: project_search_tabs?(:milestones) || @project.nil? },
- users: { label: _("Users"), condition: show_user_search_tab? },
- snippet_titles: { label: _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil }, condition: @show_snippets.present? && @project.nil? }
+ projects: { sort: 1, label: _("Projects"), data: { qa_selector: 'projects_tab' }, condition: @project.nil? },
+ blobs: { sort: 2, label: _("Code"), data: { qa_selector: 'code_tab' }, condition: project_search_tabs?(:blobs) || (search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_code_tab)) },
+ # sort: 3 is reserved for EE items
+ issues: { sort: 4, label: _("Issues"), condition: project_search_tabs?(:issues) || feature_flag_tab_enabled?(:global_search_issues_tab) },
+ merge_requests: { sort: 5, label: _("Merge requests"), condition: project_search_tabs?(:merge_requests) || feature_flag_tab_enabled?(:global_search_merge_requests_tab) },
+ wiki_blobs: { sort: 6, label: _("Wiki"), condition: project_search_tabs?(:wiki) || search_service.show_elasticsearch_tabs? },
+ commits: { sort: 7, label: _("Commits"), condition: project_search_tabs?(:commits) || (search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_commits_tab)) },
+ notes: { sort: 8, label: _("Comments"), condition: project_search_tabs?(:notes) || search_service.show_elasticsearch_tabs? },
+ milestones: { sort: 9, label: _("Milestones"), condition: project_search_tabs?(:milestones) || @project.nil? },
+ users: { sort: 10, label: _("Users"), condition: show_user_search_tab? },
+ snippet_titles: { sort: 11, label: _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil }, condition: @show_snippets.present? && @project.nil? }
}
end
def search_navigation_json
- search_navigation.each_with_object({}) do |(key, value), hash|
+ sorted_navigation = search_navigation.sort_by { |_, h| h[:sort] }
+
+ sorted_navigation.each_with_object({}) do |(key, value), hash|
hash[key] = search_filter_link_json(key, value[:label], value[:data], value[:search]) if value[:condition]
end.to_json
end
diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb
index 14ee6007a43..99da9a7af6c 100644
--- a/app/helpers/selects_helper.rb
+++ b/app/helpers/selects_helper.rb
@@ -1,30 +1,6 @@
# frozen_string_literal: true
module SelectsHelper
- def users_select_tag(id, opts = {})
- css_class = ["ajax-users-select"]
- css_class << "multiselect" if opts[:multiple]
- css_class << "skip_ldap" if opts[:skip_ldap]
- css_class << (opts[:class] || '')
- value = opts[:selected] || ''
- html = {
- class: css_class.join(' '),
- data: users_select_data_attributes(opts)
- }
-
- unless opts[:scope] == :all
- project = opts[:project] || @project
-
- if project
- html['data-project-id'] = project.id
- elsif @group
- html['data-group-id'] = @group.id
- end
- end
-
- hidden_field_tag(id, value, html)
- end
-
def groups_select_tag(id, opts = {})
classes = Array.wrap(opts[:class])
classes << 'ajax-groups-select'
@@ -68,22 +44,6 @@ module SelectsHelper
hidden_field_tag(id, value, opts)
end
-
- private
-
- def users_select_data_attributes(opts)
- {
- placeholder: opts[:placeholder] || 'Search for a user',
- null_user: opts[:null_user] || false,
- any_user: opts[:any_user] || false,
- email_user: opts[:email_user] || false,
- first_user: opts[:first_user] && current_user ? current_user.username : false,
- current_user: opts[:current_user] || false,
- author_id: opts[:author_id] || '',
- skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil,
- qa_selector: opts[:qa_selector] || ''
- }
- end
end
SelectsHelper.prepend_mod_with('SelectsHelper')
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 520cde9ecee..be63d28600f 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -15,21 +15,25 @@ module TodosHelper
def todo_action_name(todo)
case todo.action
- when Todo::ASSIGNED then todo.self_added? ? 'assigned' : 'assigned you'
- when Todo::REVIEW_REQUESTED then 'requested a review of'
- when Todo::MENTIONED, Todo::DIRECTLY_ADDRESSED then "mentioned #{todo_action_subject(todo)} on"
- when Todo::BUILD_FAILED then 'The pipeline failed in'
- when Todo::MARKED then 'added a todo for'
- when Todo::APPROVAL_REQUIRED then "set #{todo_action_subject(todo)} as an approver for"
- when Todo::UNMERGEABLE then 'Could not merge'
- when Todo::MERGE_TRAIN_REMOVED then "Removed from Merge Train:"
+ when Todo::ASSIGNED then todo.self_added? ? _('assigned') : _('assigned you')
+ when Todo::REVIEW_REQUESTED then s_('Todos|requested a review of')
+ when Todo::MENTIONED, Todo::DIRECTLY_ADDRESSED then format(
+ s_("Todos|mentioned %{who} on"), who: todo_action_subject(todo)
+ )
+ when Todo::BUILD_FAILED then s_('Todos|The pipeline failed in')
+ when Todo::MARKED then s_('Todos|added a todo for')
+ when Todo::APPROVAL_REQUIRED then format(
+ s_("Todos|set %{who} as an approver for"), who: todo_action_subject(todo)
+ )
+ when Todo::UNMERGEABLE then s_('Todos|Could not merge')
+ when Todo::MERGE_TRAIN_REMOVED then s_("Todos|Removed from Merge Train:")
end
end
def todo_self_addressing(todo)
case todo.action
- when Todo::ASSIGNED then 'to yourself'
- when Todo::REVIEW_REQUESTED then 'from yourself'
+ when Todo::ASSIGNED then _('to yourself')
+ when Todo::REVIEW_REQUESTED then _('from yourself')
end
end
@@ -66,9 +70,9 @@ module TodosHelper
return _('alert') if todo.for_alert?
target_type = if todo.for_issue_or_work_item?
- todo.target.issue_type
+ IntegrationsHelper.integration_issue_type(todo.target.issue_type)
else
- todo.target_type
+ IntegrationsHelper.integration_todo_target_type(todo.target_type)
end
target_type.titleize.downcase
@@ -109,12 +113,18 @@ module TodosHelper
return unless show_todo_state?(todo)
state = todo.target.state.to_s
+ raw_state_to_i18n = {
+ "closed" => _('Closed'),
+ "merged" => _('Merged'),
+ "resolved" => _('Resolved')
+ }
case todo.target
when MergeRequest
- if state == 'closed'
+ case state
+ when 'closed'
background_class = 'gl-bg-red-500'
- elsif state == 'merged'
+ when 'merged'
background_class = 'gl-bg-blue-500'
end
when Issue
@@ -124,7 +134,7 @@ module TodosHelper
end
tag.span class: "gl-my-0 gl-px-2 status-box #{background_class}" do
- todo.target.state.to_s.capitalize
+ raw_state_to_i18n[state] || state.capitalize
end
end
@@ -237,7 +247,7 @@ module TodosHelper
end
def todo_action_subject(todo)
- todo.self_added? ? 'yourself' : 'you'
+ todo.self_added? ? s_('Todos|yourself') : _('you')
end
def show_todo_state?(todo)
diff --git a/app/mailers/emails/identity_verification.rb b/app/mailers/emails/identity_verification.rb
index e3089fdef9b..f3fe609e7d1 100644
--- a/app/mailers/emails/identity_verification.rb
+++ b/app/mailers/emails/identity_verification.rb
@@ -2,14 +2,22 @@
module Emails
module IdentityVerification
- def verification_instructions_email(user_id, token:, expires_in:)
+ def verification_instructions_email(email, token:)
@token = token
- @expires_in_minutes = expires_in
+ @expires_in_minutes = Users::EmailVerification::ValidateTokenService::TOKEN_VALID_FOR_MINUTES
@password_link = edit_profile_password_url
@two_fa_link = help_page_url('user/profile/account/two_factor_authentication')
- user = User.find(user_id)
- email_with_layout(to: user.email, subject: s_('IdentityVerification|Verify your identity'))
+ headers = {
+ to: email,
+ subject: s_('IdentityVerification|Verify your identity'),
+ 'X-Mailgun-Suppressions-Bypass' => 'true'
+ }
+
+ mail_with_locale(headers) do |format|
+ format.html { render layout: 'mailer' }
+ format.text
+ end
end
end
end
diff --git a/app/mailers/emails/releases.rb b/app/mailers/emails/releases.rb
index 8fe93f59662..468a8624319 100644
--- a/app/mailers/emails/releases.rb
+++ b/app/mailers/emails/releases.rb
@@ -11,6 +11,8 @@ module Emails
)
@recipient = User.find(user_id)
+ add_project_headers
+
mail_with_locale(
to: @recipient.notification_email_for(@project.group),
subject: subject(release_email_subject)
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index 206518e582b..7d7e01950c8 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -210,7 +210,7 @@ class NotifyPreview < ActionMailer::Preview
end
def verification_instructions_email
- Notify.verification_instructions_email(user.id, token: '123456', expires_in: 60).message
+ Notify.verification_instructions_email(user.email, token: '123456').message
end
def project_was_exported_email
diff --git a/app/models/active_session.rb b/app/models/active_session.rb
index 7dbc95c251b..b16c4a2b353 100644
--- a/app/models/active_session.rb
+++ b/app/models/active_session.rb
@@ -83,24 +83,26 @@ class ActiveSession
is_impersonated: request.session[:impersonator_id].present?
)
- redis.pipelined do |pipeline|
- pipeline.setex(
- key_name(user.id, session_private_id),
- expiry,
- 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
- )
+ Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
+ redis.pipelined do |pipeline|
+ pipeline.setex(
+ key_name(user.id, session_private_id),
+ expiry,
+ 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
+ )
+ end
end
end
end
@@ -298,7 +300,7 @@ class ActiveSession
session_ids_and_entries.each do |session_id, entry|
next if entry
- pipeline.srem(lookup_key, session_id)
+ pipeline.srem?(lookup_key, session_id)
removed << session_id
end
end
diff --git a/app/models/alert_management/http_integration.rb b/app/models/alert_management/http_integration.rb
index 0c3b1679dc3..b2686924363 100644
--- a/app/models/alert_management/http_integration.rb
+++ b/app/models/alert_management/http_integration.rb
@@ -13,8 +13,7 @@ module AlertManagement
key: Settings.attr_encrypted_db_key_base_32,
algorithm: 'aes-256-gcm'
- default_value_for(:endpoint_identifier, allows_nil: false) { SecureRandom.hex(8) }
- default_value_for(:token) { generate_token }
+ attribute :endpoint_identifier, default: -> { SecureRandom.hex(8) }
validates :project, presence: true
validates :active, inclusion: { in: [true, false] }
diff --git a/app/models/appearance.rb b/app/models/appearance.rb
index 00a95070691..bd948c2c32a 100644
--- a/app/models/appearance.rb
+++ b/app/models/appearance.rb
@@ -6,6 +6,16 @@ class Appearance < ApplicationRecord
include ObjectStorage::BackgroundMove
include WithUploads
+ attribute :title, default: ''
+ attribute :description, default: ''
+ attribute :new_project_guidelines, default: ''
+ attribute :profile_image_guidelines, default: ''
+ attribute :header_message, default: ''
+ attribute :footer_message, default: ''
+ attribute :message_background_color, default: '#E75E40'
+ attribute :message_font_color, default: '#FFFFFF'
+ attribute :email_header_and_footer_enabled, default: false
+
cache_markdown_field :description
cache_markdown_field :new_project_guidelines
cache_markdown_field :profile_image_guidelines
@@ -20,16 +30,6 @@ class Appearance < ApplicationRecord
validate :single_appearance_row, on: :create
- default_value_for :title, ''
- default_value_for :description, ''
- default_value_for :new_project_guidelines, ''
- default_value_for :profile_image_guidelines, ''
- default_value_for :header_message, ''
- default_value_for :footer_message, ''
- default_value_for :message_background_color, '#E75E40'
- default_value_for :message_font_color, '#FFFFFF'
- default_value_for :email_header_and_footer_enabled, false
-
mount_uploader :logo, AttachmentUploader
mount_uploader :header_logo, AttachmentUploader
mount_uploader :favicon, FaviconUploader
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 361b1a8dca9..adbbddd635c 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -20,6 +20,7 @@ class ApplicationSetting < ApplicationRecord
'Admin Area > Settings > General > Kroki'
enum whats_new_variant: { all_tiers: 0, current_tier: 1, disabled: 2 }, _prefix: true
+ enum email_confirmation_setting: { off: 0, soft: 1, hard: 2 }
add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption) ? :optional : :required }
add_authentication_token_field :health_check_access_token
@@ -74,9 +75,9 @@ class ApplicationSetting < ApplicationRecord
cache_markdown_field :shared_runners_text, pipeline: :plain_markdown
cache_markdown_field :after_sign_up_text
- default_value_for :id, 1
- default_value_for :repository_storages_weighted, {}
- default_value_for :kroki_formats, {}
+ attribute :id, default: 1
+ attribute :repository_storages_weighted, default: -> { {} }
+ attribute :kroki_formats, default: -> { {} }
chronic_duration_attr_writer :archive_builds_in_human_readable, :archive_builds_in_seconds
@@ -317,6 +318,7 @@ class ApplicationSetting < ApplicationRecord
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},
@@ -527,6 +529,11 @@ class ApplicationSetting < ApplicationRecord
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
+
with_options(presence: true, numericality: { only_integer: true, greater_than: 0 }) do
validates :throttle_unauthenticated_api_requests_per_period
validates :throttle_unauthenticated_api_period_in_seconds
@@ -632,10 +639,6 @@ class ApplicationSetting < ApplicationRecord
validates :inactive_projects_send_warning_email_after_months,
numericality: { only_integer: true, greater_than: 0, less_than: :inactive_projects_delete_after_months }
- validates :cube_api_base_url,
- addressable_url: { allow_localhost: true, allow_local_network: false },
- allow_blank: true
-
attr_encrypted :asset_proxy_secret_key,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
@@ -675,10 +678,15 @@ class ApplicationSetting < ApplicationRecord
attr_encrypted :arkose_labs_private_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
attr_encrypted :cube_api_key, encryption_options_base_32_aes_256_gcm
attr_encrypted :jitsu_administrator_password, encryption_options_base_32_aes_256_gcm
+ 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)
validates :disable_feed_token,
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') }
+
before_validation :ensure_uuid!
before_validation :coerce_repository_storages_weighted, if: :repository_storages_weighted_changed?
before_validation :normalize_default_branch_name
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index dee4bd07fd9..308c05d638c 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -62,6 +62,7 @@ module ApplicationSettingImplementation
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,
+ disable_admin_oauth_scopes: false,
disable_feed_token: false,
disabled_oauth_sign_in_sources: [],
dns_rebinding_protection_enabled: true,
@@ -103,6 +104,7 @@ module ApplicationSettingImplementation
invisible_captcha_enabled: false,
issues_create_limit: 300,
jira_connect_application_key: nil,
+ jira_connect_proxy_url: nil,
local_markdown_version: 0,
login_recaptcha_protection_enabled: false,
mailgun_signing_key: nil,
diff --git a/app/models/awareness_session.rb b/app/models/awareness_session.rb
index a84a3454a27..0b652984630 100644
--- a/app/models/awareness_session.rb
+++ b/app/models/awareness_session.rb
@@ -63,16 +63,18 @@ class AwarenessSession # rubocop:disable Gitlab/NamespacedClass
user_key = user_sessions_key(user.id)
with_redis do |redis|
- redis.pipelined do |pipeline|
- pipeline.sadd(user_key, id_i)
- pipeline.expire(user_key, USER_LIFETIME.to_i)
+ 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)
+ 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)
+ # 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
@@ -83,26 +85,33 @@ class AwarenessSession # rubocop:disable Gitlab/NamespacedClass
user_key = user_sessions_key(user.id)
with_redis do |redis|
- redis.pipelined do |pipeline|
- pipeline.srem(user_key, id_i)
- pipeline.zrem(users_key, user.id)
+ 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 = redis.pipelined do |pipeline|
- pipeline.scard(user_key)
- pipeline.zcard(users_key)
- end
+ 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
- redis.pipelined do |pipeline|
- pipeline.del(user_key) unless user_sessions_count > 0
+ 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
+ unless session_users_count > 0
+ pipeline.del(users_key)
+ @id = nil
+ end
end
end
end
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index 1f921c71984..c5a234ffa69 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -23,8 +23,8 @@ class BroadcastMessage < ApplicationRecord
validates :color, allow_blank: true, color: true
validates :font, allow_blank: true, color: true
- default_value_for :color, '#E75E40'
- default_value_for :font, '#FFFFFF'
+ attribute :color, default: '#E75E40'
+ attribute :font, default: '#FFFFFF'
CACHE_KEY = 'broadcast_message_current_json'
BANNER_CACHE_KEY = 'broadcast_message_current_banner_json'
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 323d759510e..d6051d70503 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -21,7 +21,6 @@ module Ci
has_many :sourced_pipelines, class_name: "::Ci::Sources::Pipeline",
foreign_key: :source_job_id
- has_one :sourced_pipeline, class_name: "::Ci::Sources::Pipeline", foreign_key: :source_job_id
has_one :downstream_pipeline, through: :sourced_pipeline, source: :pipeline
validates :ref, presence: true
@@ -58,11 +57,7 @@ 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
+ false
end
def self.with_preloads
@@ -183,7 +178,7 @@ module Ci
false
end
- def prevent_rollback_deployment?
+ def outdated_deployment?
false
end
@@ -288,7 +283,11 @@ module Ci
return [] unless forward_yaml_variables?
yaml_variables.to_a.map do |hash|
- { key: hash[:key], value: ::ExpandVariables.expand(hash[:value], expand_variables) }
+ if hash[:raw] && ci_raw_variables_in_yaml_config_enabled?
+ { key: hash[:key], value: hash[:value], raw: true }
+ else
+ { key: hash[:key], value: ::ExpandVariables.expand(hash[:value], expand_variables) }
+ end
end
end
@@ -296,7 +295,11 @@ module Ci
return [] unless forward_pipeline_variables?
pipeline.variables.to_a.map do |variable|
- { key: variable.key, value: ::ExpandVariables.expand(variable.value, expand_variables) }
+ if variable.raw? && ci_raw_variables_in_yaml_config_enabled?
+ { key: variable.key, value: variable.value, raw: true }
+ else
+ { key: variable.key, value: ::ExpandVariables.expand(variable.value, expand_variables) }
+ end
end
end
@@ -305,7 +308,11 @@ module Ci
return [] unless pipeline.pipeline_schedule
pipeline.pipeline_schedule.variables.to_a.map do |variable|
- { key: variable.key, value: ::ExpandVariables.expand(variable.value, expand_variables) }
+ if variable.raw? && ci_raw_variables_in_yaml_config_enabled?
+ { key: variable.key, value: variable.value, raw: true }
+ else
+ { key: variable.key, value: ::ExpandVariables.expand(variable.value, expand_variables) }
+ end
end
end
@@ -324,6 +331,12 @@ module Ci
result.nil? ? FORWARD_DEFAULTS[:pipeline_variables] : result
end
end
+
+ def ci_raw_variables_in_yaml_config_enabled?
+ strong_memoize(:ci_raw_variables_in_yaml_config_enabled) do
+ ::Feature.enabled?(:ci_raw_variables_in_yaml_config, project)
+ end
+ end
end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index b8511536e32..f44ba124fe2 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -72,33 +72,6 @@ module Ci
delegate :trigger_short_token, to: :trigger_request, allow_nil: true
delegate :ensure_persistent_ref, to: :pipeline
- ##
- # Since Gitlab 11.5, deployments records started being created right after
- # `ci_builds` creation. We can look up a relevant `environment` through
- # `deployment` relation today.
- # (See more https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/22380)
- #
- # Since Gitlab 12.9, we started persisting the expanded environment name to
- # avoid repeated variables expansion in `action: stop` builds as well.
- def persisted_environment
- return unless has_environment?
-
- strong_memoize(:persisted_environment) do
- # This code path has caused N+1s in the past, since environments are only indirectly
- # associated to builds and pipelines; see https://gitlab.com/gitlab-org/gitlab/-/issues/326445
- # We therefore batch-load them to prevent dormant N+1s until we found a proper solution.
- BatchLoader.for(expanded_environment_name).batch(key: project_id) do |names, loader, args|
- Environment.where(name: names, project: args[:key]).find_each do |environment|
- loader.call(environment.name, environment)
- end
- end
- end
- end
-
- def persisted_environment=(environment)
- strong_memoize(:persisted_environment) { environment }
- end
-
serialize :options # rubocop:disable Cop/ActiveRecordSerialize
serialize :yaml_variables, Gitlab::Serializer::Ci::Variables # rubocop:disable Cop/ActiveRecordSerialize
@@ -199,7 +172,7 @@ module Ci
add_authentication_token_field :token, encrypted: :required
- before_save :ensure_token
+ before_save :ensure_token, unless: :assign_token_on_scheduling?
after_save :stick_build_if_status_changed
@@ -218,10 +191,6 @@ module Ci
preload(:job_artifacts_archive, :job_artifacts, :tags, project: [:namespace])
end
- def extra_accessors
- []
- end
-
def clone_accessors
%i[pipeline project ref tag options name
allow_failure stage stage_idx trigger_request
@@ -278,6 +247,14 @@ module Ci
!build.waiting_for_deployment_approval? # If false is returned, it stops the transition
end
+ before_transition any => [:pending] do |build, transition|
+ if build.assign_token_on_scheduling?
+ build.ensure_token
+ end
+
+ true
+ end
+
after_transition created: :scheduled do |build|
build.run_after_commit do
Ci::BuildScheduleWorker.perform_at(build.scheduled_at, build.id)
@@ -445,9 +422,10 @@ module Ci
manual? && starts_environment? && deployment&.blocked?
end
- def prevent_rollback_deployment?
- strong_memoize(:prevent_rollback_deployment) do
+ def outdated_deployment?
+ strong_memoize(:outdated_deployment) do
starts_environment? &&
+ incomplete? &&
project.ci_forward_deployment_enabled? &&
deployment&.older_than_last_successful_deployment?
end
@@ -494,8 +472,34 @@ module Ci
Gitlab::Ci::Build::Prerequisite::Factory.new(self).unmet
end
+ def persisted_environment
+ return unless has_environment_keyword?
+
+ strong_memoize(:persisted_environment) do
+ # This code path has caused N+1s in the past, since environments are only indirectly
+ # associated to builds and pipelines; see https://gitlab.com/gitlab-org/gitlab/-/issues/326445
+ # We therefore batch-load them to prevent dormant N+1s until we found a proper solution.
+ BatchLoader.for(expanded_environment_name).batch(key: project_id) do |names, loader, args|
+ Environment.where(name: names, project: args[:key]).find_each do |environment|
+ loader.call(environment.name, environment)
+ end
+ end
+ end
+ end
+
+ def persisted_environment=(environment)
+ strong_memoize(:persisted_environment) { environment }
+ end
+
+ # If build.persisted_environment is a BatchLoader, we need to remove
+ # the method proxy in order to clone into new item here
+ # https://github.com/exAspArk/batch-loader/issues/31
+ def actual_persisted_environment
+ persisted_environment.respond_to?(:__sync) ? persisted_environment.__sync : persisted_environment
+ end
+
def expanded_environment_name
- return unless has_environment?
+ return unless has_environment_keyword?
strong_memoize(:expanded_environment_name) do
# We're using a persisted expanded environment name in order to avoid
@@ -509,7 +513,7 @@ module Ci
end
def expanded_kubernetes_namespace
- return unless has_environment?
+ return unless has_environment_keyword?
namespace = options.dig(:environment, :kubernetes, :namespace)
@@ -520,16 +524,16 @@ module Ci
end
end
- def has_environment?
+ def has_environment_keyword?
environment.present?
end
def starts_environment?
- has_environment? && self.environment_action == 'start'
+ has_environment_keyword? && self.environment_action == 'start'
end
def stops_environment?
- has_environment? && self.environment_action == 'stop'
+ has_environment_keyword? && self.environment_action == 'stop'
end
def environment_action
@@ -971,7 +975,7 @@ module Ci
def collect_codequality_reports!(codequality_report)
each_report(Ci::JobArtifact.file_types_for_report(:codequality)) do |file_type, blob|
- Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, codequality_report)
+ Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, codequality_report, { project: project, commit_sha: pipeline.sha })
end
codequality_report
@@ -1043,7 +1047,8 @@ module Ci
# TODO: Have `debug_mode?` check against data on sent back from runner
# to capture all the ways that variables can be set.
# See (https://gitlab.com/gitlab-org/gitlab/-/issues/290955)
- variables['CI_DEBUG_TRACE']&.value&.casecmp('true') == 0
+ variables['CI_DEBUG_TRACE']&.value&.casecmp('true') == 0 ||
+ variables['CI_DEBUG_SERVICES']&.value&.casecmp('true') == 0
end
def drop_with_exit_code!(failure_reason, exit_code)
@@ -1131,6 +1136,10 @@ module Ci
end
end
+ def assign_token_on_scheduling?
+ ::Feature.enabled?(:ci_assign_job_token_on_scheduling, project)
+ end
+
protected
def run_status_commit_hooks!
@@ -1185,7 +1194,7 @@ module Ci
def environment_status
strong_memoize(:environment_status) do
- if has_environment? && merge_request
+ if has_environment_keyword? && merge_request
EnvironmentStatus.new(project, persisted_environment, merge_request, pipeline.sha)
end
end
@@ -1205,8 +1214,6 @@ module Ci
def legacy_jwt_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
- break variables unless Feature.enabled?(:ci_job_jwt, project)
-
jwt = Gitlab::Ci::Jwt.for_build(self)
jwt_v2 = Gitlab::Ci::JwtV2.for_build(self)
variables.append(key: 'CI_JOB_JWT', value: jwt, public: false, masked: true)
diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb
index 33092e881f0..2f28509f812 100644
--- a/app/models/ci/build_metadata.rb
+++ b/app/models/ci/build_metadata.rb
@@ -5,6 +5,7 @@ module Ci
# Data that should be persisted forever, should be stored with Ci::Build model.
class BuildMetadata < Ci::ApplicationRecord
BuildTimeout = Struct.new(:value, :source)
+ ROUTING_FEATURE_FLAG = :ci_partitioning_use_ci_builds_metadata_routing_table
include Ci::Partitionable
include Presentable
@@ -13,7 +14,12 @@ module Ci
self.table_name = 'ci_builds_metadata'
self.primary_key = 'id'
- partitionable scope: :build
+ self.sequence_name = 'ci_builds_metadata_id_seq'
+
+ partitionable scope: :build, through: {
+ table: :p_ci_builds_metadata,
+ flag: ROUTING_FEATURE_FLAG
+ }
belongs_to :build, class_name: 'CommitStatus'
belongs_to :project
@@ -24,9 +30,9 @@ module Ci
validates :id_tokens, json_schema: { filename: 'build_metadata_id_tokens' }
validates :secrets, json_schema: { filename: 'build_metadata_secrets' }
- serialize :config_options, Serializers::SymbolizedJson # rubocop:disable Cop/ActiveRecordSerialize
- serialize :config_variables, Serializers::SymbolizedJson # rubocop:disable Cop/ActiveRecordSerialize
- serialize :runtime_runner_features, Serializers::SymbolizedJson # rubocop:disable Cop/ActiveRecordSerialize
+ attribute :config_options, :sym_jsonb
+ attribute :config_variables, :sym_jsonb
+ attribute :runtime_runner_features, :sym_jsonb
chronic_duration_attr_reader :timeout_human_readable, :timeout
@@ -50,7 +56,7 @@ module Ci
end
def set_cancel_gracefully
- runtime_runner_features.merge!( { cancel_gracefully: true } )
+ runtime_runner_features.merge!({ cancel_gracefully: true })
end
def cancel_gracefully?
diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb
index 221a2284106..7baa98b59f9 100644
--- a/app/models/ci/build_trace_chunk.rb
+++ b/app/models/ci/build_trace_chunk.rb
@@ -10,7 +10,7 @@ module Ci
belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id
- default_value_for :data_store, :redis_trace_chunks
+ attribute :data_store, default: :redis_trace_chunks
after_create { metrics.increment_trace_operation(operation: :chunked) }
diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb
index e11edbda6dc..508aaa5a63c 100644
--- a/app/models/ci/group_variable.rb
+++ b/app/models/ci/group_variable.rb
@@ -5,6 +5,7 @@ module Ci
include Ci::HasVariable
include Presentable
include Ci::Maskable
+ include Ci::RawVariable
prepend HasEnvironmentScope
belongs_to :group, class_name: "::Group"
diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb
index da9d4dea537..3e572dbe18f 100644
--- a/app/models/ci/instance_variable.rb
+++ b/app/models/ci/instance_variable.rb
@@ -5,6 +5,7 @@ module Ci
extend Gitlab::ProcessMemoryCache::Helper
include Ci::NewHasVariable
include Ci::Maskable
+ include Ci::RawVariable
include Limitable
self.limit_name = 'ci_instance_level_variables'
diff --git a/app/models/ci/job_variable.rb b/app/models/ci/job_variable.rb
index 44bd3fe8901..332a78b66ae 100644
--- a/app/models/ci/job_variable.rb
+++ b/app/models/ci/job_variable.rb
@@ -3,6 +3,7 @@
module Ci
class JobVariable < Ci::ApplicationRecord
include Ci::NewHasVariable
+ include Ci::RawVariable
include BulkInsertSafe
belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index cc5ba41191b..020f5cf9d8e 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -121,7 +121,7 @@ module Ci
accepts_nested_attributes_for :variables, reject_if: :persisted?
delegate :full_path, to: :project, prefix: true
- delegate :title, to: :pipeline_metadata, allow_nil: true
+ delegate :name, to: :pipeline_metadata, allow_nil: true
validates :sha, presence: { unless: :importing? }
validates :ref, presence: { unless: :importing? }
@@ -183,7 +183,11 @@ module Ci
end
event :succeed do
- transition any - [:success] => :success
+ # A success pipeline can also be retried, for example; a pipeline with a failed manual job.
+ # When retrying the pipeline, the status of the pipeline is not changed because the failed
+ # manual job transitions to the `manual` status.
+ # More info: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/98967#note_1144718316
+ transition any => :success
end
event :cancel do
diff --git a/app/models/ci/pipeline_metadata.rb b/app/models/ci/pipeline_metadata.rb
index c96b395b45f..2bd206c5ca5 100644
--- a/app/models/ci/pipeline_metadata.rb
+++ b/app/models/ci/pipeline_metadata.rb
@@ -9,6 +9,6 @@ module Ci
validates :pipeline, presence: true
validates :project, presence: true
- validates :title, presence: true, length: { minimum: 1, maximum: 255 }
+ validates :name, presence: true, length: { minimum: 1, maximum: 255 }
end
end
diff --git a/app/models/ci/pipeline_schedule_variable.rb b/app/models/ci/pipeline_schedule_variable.rb
index 84a24609cc7..718ed14edeb 100644
--- a/app/models/ci/pipeline_schedule_variable.rb
+++ b/app/models/ci/pipeline_schedule_variable.rb
@@ -3,6 +3,7 @@
module Ci
class PipelineScheduleVariable < Ci::ApplicationRecord
include Ci::HasVariable
+ include Ci::RawVariable
belongs_to :pipeline_schedule
diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb
index 6e4418bc360..8e83b41cd0b 100644
--- a/app/models/ci/pipeline_variable.rb
+++ b/app/models/ci/pipeline_variable.rb
@@ -4,6 +4,7 @@ module Ci
class PipelineVariable < Ci::ApplicationRecord
include Ci::Partitionable
include Ci::HasVariable
+ include Ci::RawVariable
belongs_to :pipeline
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index 09dc9d4bce1..eb805ffae0a 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -7,6 +7,7 @@ module Ci
extend ::Gitlab::Utils::Override
has_one :resource, class_name: 'Ci::Resource', foreign_key: 'build_id', inverse_of: :processable
+ has_one :sourced_pipeline, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_job_id, inverse_of: :source_job
belongs_to :resource_group, class_name: 'Ci::ResourceGroup', inverse_of: :processables
diff --git a/app/models/ci/secure_file.rb b/app/models/ci/secure_file.rb
index ffff7eebbee..df38398e5a9 100644
--- a/app/models/ci/secure_file.rb
+++ b/app/models/ci/secure_file.rb
@@ -27,7 +27,7 @@ module Ci
serialize :metadata, Serializers::Json # rubocop:disable Cop/ActiveRecordSerialize
- default_value_for(:file_store) { Ci::SecureFileUploader.default_store }
+ attribute :file_store, default: -> { Ci::SecureFileUploader.default_store }
mount_file_store_uploader Ci::SecureFileUploader
diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb
index c80c2ebe69a..f4e17b5d812 100644
--- a/app/models/ci/variable.rb
+++ b/app/models/ci/variable.rb
@@ -5,6 +5,7 @@ module Ci
include Ci::HasVariable
include Presentable
include Ci::Maskable
+ include Ci::RawVariable
prepend HasEnvironmentScope
belongs_to :project
diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb
index 2a051233de2..11f84940c38 100644
--- a/app/models/clusters/applications/cert_manager.rb
+++ b/app/models/clusters/applications/cert_manager.rb
@@ -15,11 +15,8 @@ module Clusters
include ::Clusters::Concerns::ApplicationVersion
include ::Clusters::Concerns::ApplicationData
- default_value_for :version, VERSION
-
- default_value_for :email do |cert_manager|
- cert_manager.cluster&.user&.email
- end
+ attribute :version, default: VERSION
+ after_initialize :set_default_email, if: :new_record?
validates :email, presence: true
@@ -55,6 +52,10 @@ module Clusters
private
+ def set_default_email
+ self.email ||= self.cluster&.user&.email
+ end
+
def pre_install_script
[
apply_file("https://raw.githubusercontent.com/jetstack/cert-manager/release-#{CRD_VERSION}/deploy/manifests/00-crds.yaml"),
diff --git a/app/models/clusters/applications/crossplane.rb b/app/models/clusters/applications/crossplane.rb
index 07378b4e8dc..a7b4fb57149 100644
--- a/app/models/clusters/applications/crossplane.rb
+++ b/app/models/clusters/applications/crossplane.rb
@@ -14,11 +14,8 @@ module Clusters
include ::Clusters::Concerns::ApplicationVersion
include ::Clusters::Concerns::ApplicationData
- default_value_for :version, VERSION
-
- default_value_for :stack do |crossplane|
- ''
- end
+ attribute :version, default: VERSION
+ attribute :stack, default: ""
validates :stack, presence: true
diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb
index e89cb8be1e7..9fac852ed5b 100644
--- a/app/models/clusters/applications/helm.rb
+++ b/app/models/clusters/applications/helm.rb
@@ -18,7 +18,7 @@ module Clusters
include ::Clusters::Concerns::ApplicationStatus
include ::Gitlab::Utils::StrongMemoize
- default_value_for :version, Gitlab::Kubernetes::Helm::V2::BaseCommand::HELM_VERSION
+ attribute :version, default: Gitlab::Kubernetes::Helm::V2::BaseCommand::HELM_VERSION
before_create :create_keys_and_certs
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index 27550616002..034b178d67d 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -17,12 +17,11 @@ module Clusters
include AfterCommitQueue
include UsageStatistics
- default_value_for :ingress_type, :nginx
- default_value_for :version, VERSION
+ attribute :version, default: VERSION
enum ingress_type: {
nginx: 1
- }
+ }, _default: :nginx
FETCH_IP_ADDRESS_DELAY = 30.seconds
diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb
index 8d7d9c20bfa..9c0e90d59ed 100644
--- a/app/models/clusters/applications/jupyter.rb
+++ b/app/models/clusters/applications/jupyter.rb
@@ -18,7 +18,7 @@ module Clusters
belongs_to :oauth_application, class_name: 'Doorkeeper::Application'
- default_value_for :version, VERSION
+ attribute :version, default: VERSION
def set_initial_status
return unless not_installable?
diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb
index 0e7cbb35e47..64366594583 100644
--- a/app/models/clusters/applications/knative.rb
+++ b/app/models/clusters/applications/knative.rb
@@ -43,7 +43,7 @@ module Clusters
end
end
- default_value_for :version, VERSION
+ attribute :version, default: VERSION
validates :hostname, presence: true, hostname: true
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
index d1e169a1f78..a076c871824 100644
--- a/app/models/clusters/applications/prometheus.rb
+++ b/app/models/clusters/applications/prometheus.rb
@@ -15,7 +15,7 @@ module Clusters
include ::Clusters::Concerns::ApplicationData
include AfterCommitQueue
- default_value_for :version, VERSION
+ attribute :version, default: VERSION
scope :preload_cluster_platform, -> { preload(cluster: [:platform_kubernetes]) }
@@ -24,7 +24,7 @@ module Clusters
key: Settings.attr_encrypted_db_key_base_32,
algorithm: 'aes-256-gcm'
- default_value_for(:alert_manager_token) { SecureRandom.hex }
+ after_initialize :set_alert_manager_token, if: :new_record?
after_destroy do
cluster.find_or_build_integration_prometheus.destroy
@@ -101,6 +101,10 @@ module Clusters
private
+ def set_alert_manager_token
+ self.alert_manager_token = SecureRandom.hex
+ end
+
def install_knative_metrics
return [] unless cluster.application_knative_available?
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index 1ac4cbac1da..b8ed33828bc 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -15,7 +15,7 @@ module Clusters
belongs_to :runner, class_name: 'Ci::Runner', foreign_key: :runner_id
delegate :project, :group, to: :cluster
- default_value_for :version, VERSION
+ attribute :version, default: VERSION
def chart
"#{name}/gitlab-runner"
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index ad1e7dc305f..25d41d68b9e 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -79,7 +79,7 @@ module Clusters
validates :namespace_per_environment, inclusion: { in: [true, false] }
validates :helm_major_version, inclusion: { in: [2, 3] }
- default_value_for :helm_major_version, 3
+ attribute :helm_major_version, default: 3
validate :restrict_modification, on: :update
validate :no_groups, unless: :group_type?
diff --git a/app/models/clusters/integrations/prometheus.rb b/app/models/clusters/integrations/prometheus.rb
index 899529ff49f..935d6238dba 100644
--- a/app/models/clusters/integrations/prometheus.rb
+++ b/app/models/clusters/integrations/prometheus.rb
@@ -26,7 +26,7 @@ module Clusters
key: Settings.attr_encrypted_db_key_base_32,
algorithm: 'aes-256-gcm'
- default_value_for(:alert_manager_token) { SecureRandom.hex }
+ after_initialize :set_alert_manager_token, if: :new_record?
scope :enabled, -> { where(enabled: true) }
@@ -54,6 +54,10 @@ module Clusters
private
+ def set_alert_manager_token
+ self.alert_manager_token = SecureRandom.hex
+ end
+
def activate_project_integrations
::Clusters::Applications::ActivateIntegrationWorker
.perform_async(cluster_id, ::Integrations::Prometheus.to_param)
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index 9d4f0a89403..165285b34b2 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -10,6 +10,7 @@ module Clusters
include NullifyIfBlank
RESERVED_NAMESPACES = %w(gitlab-managed-apps).freeze
+ REQUIRED_K8S_MIN_VERSION = 23
IGNORED_CONNECTION_EXCEPTIONS = [
Gitlab::UrlBlocker::BlockedUrlError,
@@ -21,6 +22,8 @@ module Clusters
OpenSSL::SSL::SSLError
].freeze
+ FailedVersionCheckError = Class.new(StandardError)
+
self.table_name = 'cluster_platforms_kubernetes'
self.reactive_cache_work_type = :external_dependency
@@ -64,9 +67,7 @@ module Clusters
unknown_authorization: nil,
rbac: 1,
abac: 2
- }
-
- default_value_for :authorization_type, :rbac
+ }, _default: :rbac
nullify_if_blank :namespace
@@ -208,6 +209,29 @@ module Clusters
kubeclient.get_ingresses(namespace: namespace).as_json
rescue Kubeclient::ResourceNotFoundError
[]
+ rescue NoMethodError => e
+ # We get NoMethodError for Kubernetes versions < 1.19. Since we only support >= 1.23
+ # we will ignore this error for previous versions. For more details read:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/371249#note_1079866043
+ return [] if server_version < REQUIRED_K8S_MIN_VERSION
+
+ raise e
+ end
+
+ def server_version
+ full_url = Gitlab::UrlSanitizer.new("#{api_url}/version").full_url
+
+ # We can't use `kubeclient` to check the cluster version because it does not support it
+ # https://github.com/ManageIQ/kubeclient/issues/309
+ response = Gitlab::HTTP.perform_request(
+ Net::HTTP::Get, full_url,
+ headers: { "Authorization" => "Bearer #{token}" },
+ cert_store: kubeclient_ssl_options[:cert_store])
+
+ Gitlab::ErrorTracking.track_exception(FailedVersionCheckError.new) unless response.success?
+
+ json_response = Gitlab::Json.parse(response.body)
+ json_response["minor"].to_i
end
def build_kube_client!
diff --git a/app/models/clusters/providers/aws.rb b/app/models/clusters/providers/aws.rb
index af2eba42721..f0f56d9ebd9 100644
--- a/app/models/clusters/providers/aws.rb
+++ b/app/models/clusters/providers/aws.rb
@@ -12,9 +12,9 @@ module Clusters
belongs_to :cluster, inverse_of: :provider_aws, class_name: 'Clusters::Cluster'
- default_value_for :region, DEFAULT_REGION
- default_value_for :num_nodes, 3
- default_value_for :instance_type, 'm5.large'
+ attribute :region, default: DEFAULT_REGION
+ attribute :num_nodes, default: 3
+ attribute :instance_type, default: "m5.large"
attr_encrypted :secret_access_key,
mode: :per_attribute_iv,
diff --git a/app/models/clusters/providers/gcp.rb b/app/models/clusters/providers/gcp.rb
index 2ca7d0249dc..fde5ed592cb 100644
--- a/app/models/clusters/providers/gcp.rb
+++ b/app/models/clusters/providers/gcp.rb
@@ -9,10 +9,10 @@ module Clusters
belongs_to :cluster, inverse_of: :provider_gcp, class_name: 'Clusters::Cluster'
- default_value_for :zone, 'us-central1-a'
- default_value_for :num_nodes, 3
- default_value_for :machine_type, 'n1-standard-2'
- default_value_for :cloud_run, false
+ attribute :zone, default: 'us-central1-a'
+ attribute :num_nodes, default: 3
+ attribute :machine_type, default: 'n1-standard-2'
+ attribute :cloud_run, default: false
scope :cloud_run, -> { where(cloud_run: true) }
diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb
index a3ee8e4f364..7d89ddde0cb 100644
--- a/app/models/commit_collection.rb
+++ b/app/models/commit_collection.rb
@@ -13,10 +13,11 @@ class CommitCollection
# container - The object the commits belong to.
# commits - The Commit instances to store.
# ref - The name of the ref (e.g. "master").
- def initialize(container, commits, ref = nil)
+ def initialize(container, commits, ref = nil, page: nil, per_page: nil, count: nil)
@container = container
@commits = commits
@ref = ref
+ @pagination = Gitlab::PaginationDelegate.new(page: page, per_page: per_page, count: count)
end
def each(&block)
@@ -113,4 +114,8 @@ class CommitCollection
def method_missing(message, *args, &block)
commits.public_send(message, *args, &block)
end
+
+ def next_page
+ @pagination.next_page
+ end
end
diff --git a/app/models/commit_signatures/gpg_signature.rb b/app/models/commit_signatures/gpg_signature.rb
index 1ce76b53da4..2ae59853520 100644
--- a/app/models/commit_signatures/gpg_signature.rb
+++ b/app/models/commit_signatures/gpg_signature.rb
@@ -49,5 +49,9 @@ module CommitSignatures
Gitlab::Gpg::Commit.new(commit)
end
+
+ def user
+ gpg_key&.user
+ end
end
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 05a258e6e26..2470eada62e 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -9,11 +9,9 @@ class CommitStatus < Ci::ApplicationRecord
include EnumWithNil
include BulkInsertableAssociations
include TaggableQueries
- include IgnorableColumns
self.table_name = 'ci_builds'
partitionable scope: :pipeline
- ignore_column :trace, remove_with: '15.6', remove_after: '2022-10-22'
belongs_to :user
belongs_to :project
@@ -23,7 +21,12 @@ class CommitStatus < Ci::ApplicationRecord
has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build
+ attribute :retried, default: false
+
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
delegate :commit, to: :pipeline
delegate :sha, :short_sha, :before_sha, to: :pipeline
@@ -98,12 +101,6 @@ class CommitStatus < Ci::ApplicationRecord
merge(or_conditions)
end
- # 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
-
- default_value_for :retried, false
-
##
# We still create some CommitStatuses outside of CreatePipelineService.
#
diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb
index 910885c833f..9a04776f1c6 100644
--- a/app/models/concerns/ci/has_status.rb
+++ b/app/models/concerns/ci/has_status.rb
@@ -110,6 +110,10 @@ module Ci
COMPLETED_STATUSES.include?(status)
end
+ def incomplete?
+ COMPLETED_STATUSES.exclude?(status)
+ end
+
def blocked?
BLOCKED_STATUS.include?(status)
end
diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb
index ff884984099..d93f4a150d5 100644
--- a/app/models/concerns/ci/metadatable.rb
+++ b/app/models/concerns/ci/metadatable.rb
@@ -87,6 +87,16 @@ module Ci
ensure_metadata.id_tokens = value
end
+ def enqueue_immediately?
+ !!options[:enqueue_immediately]
+ end
+
+ def set_enqueue_immediately!
+ # ensures that even if `config_options: nil` in the database we set the
+ # new value correctly.
+ self.options = options.merge(enqueue_immediately: true)
+ end
+
private
def read_metadata_attribute(legacy_key, metadata_key, default_value = nil)
diff --git a/app/models/concerns/ci/partitionable.rb b/app/models/concerns/ci/partitionable.rb
index df803180e77..68a6714c892 100644
--- a/app/models/concerns/ci/partitionable.rb
+++ b/app/models/concerns/ci/partitionable.rb
@@ -57,9 +57,14 @@ module Ci
end
class_methods do
- private
+ def partitionable(scope:, through: nil)
+ if through
+ define_singleton_method(:routing_table_name) { through[:table] }
+ define_singleton_method(:routing_table_name_flag) { through[:flag] }
+
+ include Partitionable::Switch
+ end
- def partitionable(scope:)
define_method(:partition_scope_value) do
strong_memoize(:partition_scope_value) do
next Ci::Pipeline.current_partition_value if respond_to?(:importing?) && importing?
diff --git a/app/models/concerns/ci/partitionable/switch.rb b/app/models/concerns/ci/partitionable/switch.rb
new file mode 100644
index 00000000000..c1bbd107e9f
--- /dev/null
+++ b/app/models/concerns/ci/partitionable/switch.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Ci
+ module Partitionable
+ module Switch
+ extend ActiveSupport::Concern
+
+ # These methods are cached at the class level and depend on the value
+ # of `table_name`, changing that value resets them.
+ # `cached_find_by_statement` is used to cache SQL statements which can
+ # include the table name.
+ #
+ SWAPABLE_METHODS = %i[table_name quoted_table_name arel_table
+ predicate_builder cached_find_by_statement].freeze
+
+ included do |base|
+ partitioned = Class.new(base) do
+ self.table_name = base.routing_table_name
+
+ def self.routing_class?
+ true
+ end
+ end
+
+ base.const_set(:Partitioned, partitioned)
+ end
+
+ class_methods do
+ def routing_class?
+ false
+ end
+
+ def routing_table_enabled?
+ return false if routing_class?
+
+ Gitlab::SafeRequestStore.fetch(routing_table_name_flag) do
+ ::Feature.enabled?(routing_table_name_flag)
+ end
+ end
+
+ # We're delegating them to the `Partitioned` model.
+ # They do not require any check override since they come from AR core
+ # (are always defined) and we're using `super` to get the value.
+ #
+ SWAPABLE_METHODS.each do |name|
+ define_method(name) do |*args, &block|
+ if routing_table_enabled?
+ self::Partitioned.public_send(name, *args, &block) # rubocop: disable GitlabSecurity/PublicSend
+ else
+ super(*args, &block)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/ci/raw_variable.rb b/app/models/concerns/ci/raw_variable.rb
new file mode 100644
index 00000000000..5cfc781c9f1
--- /dev/null
+++ b/app/models/concerns/ci/raw_variable.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Ci
+ module RawVariable
+ extend ActiveSupport::Concern
+
+ included do
+ validates :raw, inclusion: { in: [true, false] }
+ end
+
+ private
+
+ def uncached_runner_variable
+ super.merge(raw: raw?)
+ end
+ end
+end
diff --git a/app/models/concerns/ci/track_environment_usage.rb b/app/models/concerns/ci/track_environment_usage.rb
index 45d9cdeeb59..fe548c77590 100644
--- a/app/models/concerns/ci/track_environment_usage.rb
+++ b/app/models/concerns/ci/track_environment_usage.rb
@@ -17,7 +17,7 @@ module Ci
end
def verifies_environment?
- has_environment? && environment_action == 'verify'
+ has_environment_keyword? && environment_action == 'verify'
end
def count_user_deployment?
diff --git a/app/models/concerns/encrypted_user_password.rb b/app/models/concerns/encrypted_user_password.rb
new file mode 100644
index 00000000000..97e6592f442
--- /dev/null
+++ b/app/models/concerns/encrypted_user_password.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+# Support for both BCrypt and PBKDF2+SHA512 user passwords
+# Meant to be used exclusively with User model but extracted
+# to a concern for isolation and clarity.
+module EncryptedUserPassword
+ extend ActiveSupport::Concern
+
+ BCRYPT_PREFIX = '$2a$'
+ PBKDF2_SHA512_PREFIX = '$pbkdf2-sha512$'
+
+ BCRYPT_STRATEGY = :bcrypt
+ PBKDF2_SHA512_STRATEGY = :pbkdf2_sha512
+
+ # Use Devise DatabaseAuthenticatable#authenticatable_salt
+ # unless encrypted password is PBKDF2+SHA512.
+ def authenticatable_salt
+ return super unless pbkdf2_password?
+
+ Devise::Pbkdf2Encryptable::Encryptors::Pbkdf2Sha512.split_digest(encrypted_password)[:salt]
+ end
+
+ # Called by Devise during database authentication.
+ # Also migrates the user password to the configured
+ # encryption type (BCrypt or PBKDF2+SHA512), if needed.
+ def valid_password?(password)
+ return false unless password_matches?(password)
+
+ migrate_password!(password)
+ end
+
+ def password=(new_password)
+ @password = new_password # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ return unless new_password.present?
+
+ self.encrypted_password = if Gitlab::FIPS.enabled?
+ Devise::Pbkdf2Encryptable::Encryptors::Pbkdf2Sha512.digest(
+ new_password,
+ Devise::Pbkdf2Encryptable::Encryptors::Pbkdf2Sha512::STRETCHES,
+ Devise.friendly_token[0, 16])
+ else
+ Devise::Encryptor.digest(self.class, new_password)
+ end
+ end
+
+ private
+
+ def password_strategy
+ return BCRYPT_STRATEGY if encrypted_password.starts_with?(BCRYPT_PREFIX)
+ return PBKDF2_SHA512_STRATEGY if encrypted_password.starts_with?(PBKDF2_SHA512_PREFIX)
+
+ :unknown
+ end
+
+ def pbkdf2_password?
+ password_strategy == PBKDF2_SHA512_STRATEGY
+ end
+
+ def bcrypt_password?
+ password_strategy == BCRYPT_STRATEGY
+ end
+
+ def password_matches?(password)
+ if bcrypt_password?
+ Devise::Encryptor.compare(self.class, encrypted_password, password)
+ elsif pbkdf2_password?
+ Devise::Pbkdf2Encryptable::Encryptors::Pbkdf2Sha512.compare(encrypted_password, password)
+ end
+ end
+
+ def migrate_password!(password)
+ return true if password_strategy == encryptor
+
+ update_attribute(:password, password)
+ end
+
+ def encryptor
+ return BCRYPT_STRATEGY unless Gitlab::FIPS.enabled?
+
+ PBKDF2_SHA512_STRATEGY
+ end
+end
diff --git a/app/models/concerns/enums/sbom.rb b/app/models/concerns/enums/sbom.rb
index 518efa669ad..8848c0c5555 100644
--- a/app/models/concerns/enums/sbom.rb
+++ b/app/models/concerns/enums/sbom.rb
@@ -6,8 +6,23 @@ module Enums
library: 0
}.with_indifferent_access.freeze
+ PURL_TYPES = {
+ composer: 1, # refered to as `packagist` in gemnasium-db
+ conan: 2,
+ gem: 3,
+ golang: 4, # refered to as `go` in gemnasium-db
+ maven: 5,
+ npm: 6,
+ nuget: 7,
+ pypi: 8
+ }.with_indifferent_access.freeze
+
def self.component_types
COMPONENT_TYPES
end
+
+ def self.purl_types
+ PURL_TYPES
+ end
end
end
diff --git a/app/models/concerns/file_store_mounter.rb b/app/models/concerns/file_store_mounter.rb
index f1ac734635d..4d267dc69d0 100644
--- a/app/models/concerns/file_store_mounter.rb
+++ b/app/models/concerns/file_store_mounter.rb
@@ -1,31 +1,35 @@
# frozen_string_literal: true
module FileStoreMounter
+ ALLOWED_FILE_FIELDS = %i[file signed_file].freeze
+
extend ActiveSupport::Concern
class_methods do
- # When `skip_store_file: true` is used, the model MUST explicitly call `store_file_now!`
- def mount_file_store_uploader(uploader, skip_store_file: false)
- mount_uploader(:file, uploader)
+ # When `skip_store_file: true` is used, the model MUST explicitly call `store_#{file_field}_now!`
+ def mount_file_store_uploader(uploader, skip_store_file: false, file_field: :file)
+ raise ArgumentError, "file_field not allowed: #{file_field}" unless ALLOWED_FILE_FIELDS.include?(file_field)
+
+ mount_uploader(file_field, uploader)
+
+ define_method("update_#{file_field}_store") do
+ # The file.object_store is set during `uploader.store!` and `uploader.migrate!`
+ update_column("#{file_field}_store", public_send(file_field).object_store) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ define_method("store_#{file_field}_now!") do
+ public_send("store_#{file_field}!") # rubocop:disable GitlabSecurity/PublicSend
+ public_send("update_#{file_field}_store") # rubocop:disable GitlabSecurity/PublicSend
+ end
if skip_store_file
- skip_callback :save, :after, :store_file!
+ skip_callback :save, :after, "store_#{file_field}!".to_sym
return
end
# This hook is a no-op when the file is uploaded after_commit
- after_save :update_file_store, if: :saved_change_to_file?
+ after_save "update_#{file_field}_store".to_sym, if: "saved_change_to_#{file_field}?".to_sym
end
end
-
- def update_file_store
- # The file.object_store is set during `uploader.store!` and `uploader.migrate!`
- update_column(:file_store, file.object_store)
- end
-
- def store_file_now!
- store_file!
- update_file_store
- end
end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index f8389865f91..31b2a8d7cc1 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -217,6 +217,10 @@ module Issuable
false
end
+ def supports_confidentiality?
+ false
+ end
+
def severity
return IssuableSeverity::DEFAULT unless supports_severity?
@@ -236,7 +240,6 @@ module Issuable
end
def validate_assignee_size_length
- return true unless Feature.enabled?(:limit_assignees_per_issuable)
return true unless assignees.size > MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS
errors.add :assignees,
@@ -460,18 +463,6 @@ module Issuable
end
end
- def today?
- Date.today == created_at.to_date
- end
-
- def created_hours_ago
- (Time.now.utc.to_i - created_at.utc.to_i) / 3600
- end
-
- def new?
- created_hours_ago < 24
- end
-
def open?
opened?
end
diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb
index 14c54d99ef3..a95bed7ad42 100644
--- a/app/models/concerns/milestoneable.rb
+++ b/app/models/concerns/milestoneable.rb
@@ -18,7 +18,7 @@ module Milestoneable
scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
scope :without_particular_milestones, ->(titles) { left_outer_joins(:milestone).where("milestones.title NOT IN (?) OR milestone_id IS NULL", titles) }
scope :any_release, -> { joins_milestone_releases }
- scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) }
+ scope :with_release, -> (tag, project_id) { joins_milestone_releases.where(milestones: { releases: { tag: tag, project_id: project_id } }) }
scope :without_particular_release, -> (tag, project_id) { joins_milestone_releases.where.not(milestones: { releases: { tag: tag, project_id: project_id } }) }
scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") }
diff --git a/app/models/concerns/mirror_authentication.rb b/app/models/concerns/mirror_authentication.rb
index 14c8be93ce0..e3bfeaf7f95 100644
--- a/app/models/concerns/mirror_authentication.rb
+++ b/app/models/concerns/mirror_authentication.rb
@@ -11,7 +11,7 @@ module MirrorAuthentication
# We should generate a key even if there's no SSH URL present
before_validation :generate_ssh_private_key!, if: -> {
- regenerate_ssh_private_key || ( auth_method == 'ssh_public_key' && ssh_private_key.blank? )
+ regenerate_ssh_private_key || (auth_method == 'ssh_public_key' && ssh_private_key.blank?)
}
credentials_field :auth_method, reader: false
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index c1aac235d33..492d55c74e2 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -210,11 +210,23 @@ module Noteable
# Synthetic system notes don't have discussion IDs because these are generated dynamically
# in Ruby. These are always root notes anyway so we don't need to group by discussion ID.
def synthetic_note_ids_relations
- [
- resource_label_events.select("'resource_label_events'", "'NULL'", :id, :created_at),
- resource_milestone_events.select("'resource_milestone_events'", "'NULL'", :id, :created_at),
- resource_state_events.select("'resource_state_events'", "'NULL'", :id, :created_at)
- ]
+ relations = []
+
+ # currently multiple models include Noteable concern, but not all of them support
+ # all resource events, so we check if given model supports given resource event.
+ if respond_to?(:resource_label_events)
+ relations << resource_label_events.select("'resource_label_events'", "'NULL'", :id, :created_at)
+ end
+
+ if respond_to?(:resource_state_events)
+ relations << resource_state_events.select("'resource_state_events'", "'NULL'", :id, :created_at)
+ end
+
+ if respond_to?(:resource_milestone_events)
+ relations << resource_milestone_events.select("'resource_milestone_events'", "'NULL'", :id, :created_at)
+ end
+
+ relations
end
end
diff --git a/app/models/concerns/packages/debian/distribution.rb b/app/models/concerns/packages/debian/distribution.rb
index 1520ec0828e..75fd45d13a9 100644
--- a/app/models/concerns/packages/debian/distribution.rb
+++ b/app/models/concerns/packages/debian/distribution.rb
@@ -85,8 +85,7 @@ module Packages
scope :with_codename_or_suite, ->(codename_or_suite) { with_codename(codename_or_suite).or(with_suite(codename_or_suite)) }
mount_file_store_uploader Packages::Debian::DistributionReleaseFileUploader
- mount_uploader :signed_file, Packages::Debian::DistributionReleaseFileUploader
- after_save :update_signed_file_store, if: :saved_change_to_signed_file?
+ mount_file_store_uploader Packages::Debian::DistributionReleaseFileUploader, file_field: :signed_file
def component_names
components.pluck(:name).sort
@@ -119,12 +118,6 @@ module Packages
self.class.with_container(container).with_codename(suite).exists?
end
-
- def update_signed_file_store
- # The signed_file.object_store is set during `uploader.store!`
- # which happens after object is inserted/updated
- self.update_column(:signed_file_store, signed_file.object_store)
- end
end
end
end
diff --git a/app/models/concerns/pg_full_text_searchable.rb b/app/models/concerns/pg_full_text_searchable.rb
index 335fcec2611..562c8cf23f3 100644
--- a/app/models/concerns/pg_full_text_searchable.rb
+++ b/app/models/concerns/pg_full_text_searchable.rb
@@ -25,6 +25,7 @@ module PgFullTextSearchable
TSVECTOR_MAX_LENGTH = 1.megabyte.freeze
TEXT_SEARCH_DICTIONARY = 'english'
URL_SCHEME_REGEX = %r{(?<=\A|\W)\w+://(?=\w+)}.freeze
+ TSQUERY_DISALLOWED_CHARACTERS_REGEX = %r{[^a-zA-Z0-9 .@/\-_"]}.freeze
def update_search_data!
tsvector_sql_nodes = self.class.pg_full_text_searchable_columns.map do |column, weight|
@@ -102,21 +103,16 @@ module PgFullTextSearchable
end
end
- def pg_full_text_search(search_term)
+ def pg_full_text_search(query, matched_columns: [])
search_data_table = reflect_on_association(:search_data).klass.arel_table
- # This fixes an inconsistency with how to_tsvector and websearch_to_tsquery process URLs
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/354784#note_905431920
- search_term = remove_url_scheme(search_term)
- search_term = ActiveSupport::Inflector.transliterate(search_term)
-
joins(:search_data).where(
Arel::Nodes::InfixOperation.new(
'@@',
search_data_table[:search_vector],
Arel::Nodes::NamedFunction.new(
- 'websearch_to_tsquery',
- [Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), Arel::Nodes.build_quoted(search_term)]
+ 'to_tsquery',
+ [Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), build_tsquery(query, matched_columns)]
)
)
)
@@ -124,8 +120,39 @@ module PgFullTextSearchable
private
- def remove_url_scheme(search_term)
- search_term.gsub(URL_SCHEME_REGEX, '')
+ def build_tsquery(query, matched_columns)
+ # URLs get broken up into separate words when : is removed below, so we just remove the whole scheme.
+ query = remove_url_scheme(query)
+ # Remove accents from search term to match indexed data
+ query = ActiveSupport::Inflector.transliterate(query)
+ # Prevent users from using tsquery operators that can cause syntax errors.
+ query = filter_allowed_characters(query)
+
+ weights = matched_columns.map do |column_name|
+ pg_full_text_searchable_columns[column_name]
+ end.compact.join
+ prefix_search_suffix = ":*#{weights}"
+
+ tsquery = Gitlab::SQL::Pattern.split_query_to_search_terms(query).map do |search_term|
+ case search_term
+ when /\A\d+\z/ # Handles https://gitlab.com/gitlab-org/gitlab/-/issues/375337
+ "(#{search_term + prefix_search_suffix} | -#{search_term + prefix_search_suffix})"
+ when /\s/
+ search_term.split.map { |t| "#{t}:#{weights}" }.join(' <-> ')
+ else
+ search_term + prefix_search_suffix
+ end
+ end.join(' & ')
+
+ Arel::Nodes.build_quoted(tsquery)
+ end
+
+ def remove_url_scheme(query)
+ query.gsub(URL_SCHEME_REGEX, '')
+ end
+
+ def filter_allowed_characters(query)
+ query.gsub(TSQUERY_DISALLOWED_CHARACTERS_REGEX, ' ')
end
end
end
diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb
index 2976b6f02a7..d37f20e2e7c 100644
--- a/app/models/concerns/project_features_compatibility.rb
+++ b/app/models/concerns/project_features_compatibility.rb
@@ -110,6 +110,10 @@ module ProjectFeaturesCompatibility
write_feature_attribute_string(:releases_access_level, value)
end
+ def infrastructure_access_level=(value)
+ write_feature_attribute_string(:infrastructure_access_level, value)
+ end
+
# TODO: Remove this method after we drop support for project create/edit APIs to set the
# container_registry_enabled attribute. They can instead set the container_registry_access_level
# attribute.
diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb
index ec56f4a32af..7e1ebd1eba3 100644
--- a/app/models/concerns/protected_ref.rb
+++ b/app/models/concerns/protected_ref.rb
@@ -7,7 +7,6 @@ module ProtectedRef
belongs_to :project, touch: true
validates :name, presence: true
- validates :project, presence: true
delegate :matching, :matches?, :wildcard?, to: :ref_matcher
diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb
index 618ad96905d..facf0808e7a 100644
--- a/app/models/concerns/protected_ref_access.rb
+++ b/app/models/concerns/protected_ref_access.rb
@@ -21,8 +21,8 @@ module ProtectedRefAccess
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 :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) }
diff --git a/app/models/concerns/redis_cacheable.rb b/app/models/concerns/redis_cacheable.rb
index 2d4ed51ce3b..f1d29ad5a90 100644
--- a/app/models/concerns/redis_cacheable.rb
+++ b/app/models/concerns/redis_cacheable.rb
@@ -26,8 +26,8 @@ module RedisCacheable
end
def cache_attributes(values)
- Gitlab::Redis::Cache.with do |redis|
- redis.set(cache_attribute_key, values.to_json, ex: CACHED_ATTRIBUTES_EXPIRY_TIME)
+ with_redis do |redis|
+ redis.set(cache_attribute_key, Gitlab::Json.dump(values), ex: CACHED_ATTRIBUTES_EXPIRY_TIME)
end
clear_memoization(:cached_attributes)
@@ -41,13 +41,17 @@ module RedisCacheable
def cached_attributes
strong_memoize(:cached_attributes) do
- Gitlab::Redis::Cache.with do |redis|
+ with_redis do |redis|
data = redis.get(cache_attribute_key)
Gitlab::Json.parse(data, symbolize_names: true) if data
end
end
end
+ def with_redis(&block)
+ Gitlab::Redis::Cache.with(&block) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
def cast_value_from_cache(attribute, value)
self.class.type_for_attribute(attribute.to_s).cast(value)
end
diff --git a/app/models/concerns/repository_storage_movable.rb b/app/models/concerns/repository_storage_movable.rb
index b7fd52ab305..87ff413f2c1 100644
--- a/app/models/concerns/repository_storage_movable.rb
+++ b/app/models/concerns/repository_storage_movable.rb
@@ -19,9 +19,7 @@ module RepositoryStorageMovable
inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } }
validate :container_repository_writable, on: :create
- default_value_for(:destination_storage_name, allows_nil: false) do
- Repository.pick_storage_shard
- end
+ attribute :destination_storage_name, default: -> { Repository.pick_storage_shard }
state_machine initial: :initial do
event :schedule do
diff --git a/app/models/concerns/subquery.rb b/app/models/concerns/subquery.rb
new file mode 100644
index 00000000000..ae92d2137c1
--- /dev/null
+++ b/app/models/concerns/subquery.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+# Distinguish between a top level query and a subselect.
+#
+# Retrieve column values when the relation has already been loaded, otherwise reselect the relation.
+# Useful for preload query patterns where the typical Rails #preload does not fit. Such as:
+#
+# projects = Project.where(...)
+# projects.load
+# ...
+# options[members] = ProjectMember.where(...).where(source_id: projects.select(:id))
+module Subquery
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def subquery(*column_names, max_limit: 5_000)
+ if current_scope.loaded? && current_scope.size <= max_limit
+ current_scope.pluck(*column_names)
+ else
+ current_scope.reselect(*column_names)
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/ttl_expirable.rb b/app/models/concerns/ttl_expirable.rb
index 1c2147beedd..d09ce4873b1 100644
--- a/app/models/concerns/ttl_expirable.rb
+++ b/app/models/concerns/ttl_expirable.rb
@@ -4,8 +4,8 @@ module TtlExpirable
extend ActiveSupport::Concern
included do
+ attribute :read_at, default: -> { Time.zone.now }
validates :status, presence: true
- default_value_for :read_at, Time.zone.now
enum status: { default: 0, pending_destruction: 1, processing: 2, error: 3 }
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index 14520b2da26..7da4e31b472 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -6,6 +6,7 @@ class ContainerRepository < ApplicationRecord
include EachBatch
include Sortable
include AfterCommitQueue
+ include Packages::Destructible
WAITING_CLEANUP_STATUSES = %i[cleanup_scheduled cleanup_unfinished].freeze
REQUIRING_CLEANUP_STATUSES = %i[cleanup_unscheduled cleanup_scheduled].freeze
@@ -34,7 +35,7 @@ class ContainerRepository < ApplicationRecord
numericality: { greater_than_or_equal_to: 0 },
allow_nil: false
- enum status: { delete_scheduled: 0, delete_failed: 1 }
+ 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 }
enum migration_skipped_reason: {
@@ -69,6 +70,7 @@ class ContainerRepository < ApplicationRecord
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_delete_at, ->(threshold) { where('delete_started_at < ?', threshold) }
scope :import_in_process, -> { where(migration_state: %w[pre_importing pre_import_done importing]) }
scope :recently_done_migration_step, -> do
@@ -224,6 +226,13 @@ class ContainerRepository < ApplicationRecord
end
end
+ # Container Repository model and the code that makes API calls
+ # are tied. Sometimes (mainly in Geo) we need to work with Registry
+ # when Container Repository record doesn't even exist.
+ # The ability to create a not-persisted record with a certain "path" parameter
+ # is very useful
+ attr_writer :path
+
def self.exists_by_path?(path)
where(
project: path.repository_project,
@@ -278,6 +287,10 @@ class ContainerRepository < ApplicationRecord
all
end
+ class << self
+ alias_method :pending_destruction, :delete_scheduled # needed by Packages::Destructible
+ end
+
def skip_import(reason:)
self.migration_skipped_reason = reason
@@ -507,6 +520,14 @@ class ContainerRepository < ApplicationRecord
end
end
+ def set_delete_ongoing_status
+ update_columns(status: :delete_ongoing, delete_started_at: Time.zone.now)
+ end
+
+ def set_delete_scheduled_status
+ update_columns(status: :delete_scheduled, delete_started_at: nil)
+ end
+
def migration_in_active_state?
migration_state.in?(ACTIVE_MIGRATION_STATES)
end
diff --git a/app/models/cycle_analytics/project_level_stage_adapter.rb b/app/models/cycle_analytics/project_level_stage_adapter.rb
index 5538e93a39e..9b9c0822f63 100644
--- a/app/models/cycle_analytics/project_level_stage_adapter.rb
+++ b/app/models/cycle_analytics/project_level_stage_adapter.rb
@@ -4,7 +4,7 @@
# compatible with the old value stream controller actions.
module CycleAnalytics
class ProjectLevelStageAdapter
- ProjectLevelStage = Struct.new(:title, :description, :legend, :name, :project_median, keyword_init: true )
+ ProjectLevelStage = Struct.new(:title, :description, :legend, :name, :project_median, keyword_init: true)
def initialize(stage, options)
@stage = stage
diff --git a/app/models/dependency_proxy/group_setting.rb b/app/models/dependency_proxy/group_setting.rb
index bcf09b27129..3a7ae66a263 100644
--- a/app/models/dependency_proxy/group_setting.rb
+++ b/app/models/dependency_proxy/group_setting.rb
@@ -3,7 +3,7 @@
class DependencyProxy::GroupSetting < ApplicationRecord
belongs_to :group
- validates :group, presence: true
+ attribute :enabled, default: true
- default_value_for :enabled, true
+ validates :group, presence: true
end
diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb
index 20d19ec9541..66d1ce01814 100644
--- a/app/models/deploy_token.rb
+++ b/app/models/deploy_token.rb
@@ -13,7 +13,7 @@ class DeployToken < ApplicationRecord
GITLAB_DEPLOY_TOKEN_NAME = 'gitlab-deploy-token'
REQUIRED_DEPENDENCY_PROXY_SCOPES = %i[read_registry write_registry].freeze
- default_value_for(:expires_at) { Forever.date }
+ attribute :expires_at, default: -> { Forever.date }
# Do NOT use this `user` for the authentication/authorization of the deploy tokens.
# It's for the auditing purpose on Credential Inventory, only.
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 20841bc14cd..ea92b978d3a 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -306,8 +306,8 @@ class Deployment < ApplicationRecord
last_deployment_id = environment.last_deployment&.id
return false unless last_deployment_id.present?
-
return false if self.id == last_deployment_id
+ return false if self.sha == environment.last_deployment&.sha
self.id < last_deployment_id
end
@@ -439,8 +439,9 @@ class Deployment < ApplicationRecord
end
# default tag limit is 100, 0 means no limit
+ # when refs_by_oid is passed an SHA, returns refs for that commit
def tags(limit: 100)
- project.repository.tag_names_contains(sha, limit: limit)
+ project.repository.refs_by_oid(oid: sha, limit: limit, ref_patterns: [Gitlab::Git::TAG_REF_PREFIX]) || []
end
strong_memoize_attr :tags
diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb
index f4d665cf279..041ec98ffc9 100644
--- a/app/models/diff_discussion.rb
+++ b/app/models/diff_discussion.rb
@@ -37,18 +37,18 @@ class DiffDiscussion < Discussion
def reply_attributes
super.merge(
- original_position: original_position.to_json,
- position: position.to_json
+ original_position: Gitlab::Json.dump(original_position),
+ position: Gitlab::Json.dump(position)
)
end
def cache_key
- positions_json = diff_note_positions.map { |dnp| dnp.position.to_json }
+ positions_json = diff_note_positions.map { |dnp| Gitlab::Json.dump(dnp.position) }
positions_sha = Digest::SHA1.hexdigest(positions_json.join(':')) if positions_json.any?
[
super,
- Digest::SHA1.hexdigest(position.to_json),
+ Digest::SHA1.hexdigest(Gitlab::Json.dump(position)),
positions_sha
].join(':')
end
diff --git a/app/models/diff_viewer/server_side.rb b/app/models/diff_viewer/server_side.rb
index a1defb2594f..fb127de2bc7 100644
--- a/app/models/diff_viewer/server_side.rb
+++ b/app/models/diff_viewer/server_side.rb
@@ -9,14 +9,6 @@ module DiffViewer
self.size_limit = 5.megabytes
end
- def prepare!
- return if Feature.enabled?(:disable_load_entire_blob_for_diff_viewer, diff_file.repository.project)
-
- # TODO: remove this after resolving #342703
- diff_file.old_blob&.load_all_data!
- diff_file.new_blob&.load_all_data!
- end
-
def render_error
# Files that are not stored in the repository, like LFS files and
# build artifacts, can only be rendered using a client-side viewer,
diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb
index 12d73ef0d72..1c7a8d93e6e 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -104,18 +104,9 @@ module ErrorTracking
api_host
end
- def sentry_response_limit_enabled?
- Feature.enabled?(:error_tracking_sentry_limit, project)
- end
-
- def reactive_cache_limit_enabled?
- sentry_response_limit_enabled?
- end
-
def sentry_client
strong_memoize(:sentry_client) do
- ::ErrorTracking::SentryClient
- .new(api_url, token, validate_size_guarded_by_feature_flag: sentry_response_limit_enabled?)
+ ::ErrorTracking::SentryClient.new(api_url, token)
end
end
diff --git a/app/models/event.rb b/app/models/event.rb
index 4c1793d3f13..a1417db3410 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -358,7 +358,7 @@ class Event < ApplicationRecord
# hence we add the extra WHERE clause for last_activity_at.
Project.unscoped.where(id: project_id)
.where('last_activity_at <= ?', RESET_PROJECT_ACTIVITY_INTERVAL.ago)
- .touch_all(:last_activity_at, time: created_at) # rubocop: disable Rails/SkipsModelValidations
+ .touch_all(:last_activity_at, time: created_at)
Gitlab::InactiveProjectsDeletionWarningTracker.new(project.id).reset
end
@@ -441,7 +441,7 @@ class Event < ApplicationRecord
def set_last_repository_updated_at
Project.unscoped.where(id: project_id)
.where("last_repository_updated_at < ? OR last_repository_updated_at IS NULL", REPOSITORY_UPDATED_AT_INTERVAL.ago)
- .touch_all(:last_repository_updated_at, time: created_at) # rubocop: disable Rails/SkipsModelValidations
+ .touch_all(:last_repository_updated_at, time: created_at)
end
def design_action_names
diff --git a/app/models/event_collection.rb b/app/models/event_collection.rb
index 4258027aa56..72e1d28a297 100644
--- a/app/models/event_collection.rb
+++ b/app/models/event_collection.rb
@@ -62,21 +62,12 @@ class EventCollection
end
def in_operator_optimized_relation(parent_column, parents, parent_model)
- query_builder_params = if Feature.enabled?(:optimized_project_and_group_activity_queries)
- array_data = {
- scope_ids: parents.pluck(:id),
- scope_model: parent_model,
- mapping_column: parent_column
- }
- filter.in_operator_query_builder_params(array_data)
- else
- {
- scope: filtered_events,
- array_scope: parents.select(:id),
- array_mapping_scope: -> (parent_id_expression) { Event.where(Event.arel_table[parent_column].eq(parent_id_expression)).reorder(id: :desc) },
- finder_query: -> (id_expression) { Event.where(Event.arel_table[:id].eq(id_expression)) }
- }
- end
+ array_data = {
+ scope_ids: parents.pluck(:id),
+ scope_model: parent_model,
+ mapping_column: parent_column
+ }
+ query_builder_params = filter.in_operator_query_builder_params(array_data)
Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder
.new(**query_builder_params)
@@ -84,10 +75,6 @@ class EventCollection
.limit(@limit + @offset)
end
- def filtered_events
- filter.apply_filter(base_relation)
- end
-
def paginate_events(events)
events.limit(@limit).offset(@offset)
end
diff --git a/app/models/experiment.rb b/app/models/experiment.rb
deleted file mode 100644
index 2300ec2996d..00000000000
--- a/app/models/experiment.rb
+++ /dev/null
@@ -1,77 +0,0 @@
-# frozen_string_literal: true
-
-class Experiment < ApplicationRecord
- has_many :experiment_users
- has_many :experiment_subjects, inverse_of: :experiment
-
- validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
-
- def self.add_user(name, group_type, user, context = {})
- by_name(name).record_user_and_group(user, group_type, context)
- end
-
- def self.add_group(name, variant:, group:)
- add_subject(name, variant: variant, subject: group)
- end
-
- def self.add_subject(name, variant:, subject:)
- by_name(name).record_subject_and_variant!(subject, variant)
- end
-
- def self.record_conversion_event(name, user, context = {})
- by_name(name).record_conversion_event_for_user(user, context)
- end
-
- def self.by_name(name)
- find_or_create_by!(name: name)
- end
-
- # Create or update the recorded experiment_user row for the user in this experiment.
- def record_user_and_group(user, group_type, context = {})
- experiment_user = experiment_users.find_or_initialize_by(user: user)
- experiment_user.assign_attributes(group_type: group_type, context: merged_context(experiment_user, context))
- # We only call save when necessary because this causes the request to stick to the primary DB
- # even when the save is a no-op
- # https://gitlab.com/gitlab-org/gitlab/-/issues/324649
- experiment_user.save! if experiment_user.changed?
-
- experiment_user
- end
-
- def record_conversion_event_for_user(user, context = {})
- experiment_user = experiment_users.find_by(user: user)
- return unless experiment_user
-
- experiment_user.update!(converted_at: Time.current, context: merged_context(experiment_user, context))
- end
-
- def record_conversion_event_for_subject(subject, context = {})
- raise 'Incompatible subject provided!' unless ExperimentSubject.valid_subject?(subject)
-
- attr_name = subject.class.table_name.singularize.to_sym
- experiment_subject = experiment_subjects.find_by(attr_name => subject)
- return unless experiment_subject
-
- experiment_subject.update!(converted_at: Time.current, context: merged_context(experiment_subject, context))
- end
-
- def record_subject_and_variant!(subject, variant)
- raise 'Incompatible subject provided!' unless ExperimentSubject.valid_subject?(subject)
-
- attr_name = subject.class.table_name.singularize.to_sym
- experiment_subject = experiment_subjects.find_or_initialize_by(attr_name => subject)
- experiment_subject.assign_attributes(variant: variant)
- # We only call save when necessary because this causes the request to stick to the primary DB
- # even when the save is a no-op
- # https://gitlab.com/gitlab-org/gitlab/-/issues/324649
- experiment_subject.save! if experiment_subject.changed?
-
- experiment_subject
- end
-
- private
-
- def merged_context(experiment_subject, new_context)
- experiment_subject.context.deep_merge(new_context.deep_stringify_keys)
- end
-end
diff --git a/app/models/experiment_subject.rb b/app/models/experiment_subject.rb
deleted file mode 100644
index 2a7b9017a51..00000000000
--- a/app/models/experiment_subject.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-# frozen_string_literal: true
-
-class ExperimentSubject < ApplicationRecord
- include ::Gitlab::Experimentation::GroupTypes
-
- belongs_to :experiment, inverse_of: :experiment_subjects
- belongs_to :user
- belongs_to :namespace
- belongs_to :project
-
- validates :experiment, presence: true
- validates :variant, presence: true
- validate :must_have_one_subject_present
-
- enum variant: { GROUP_CONTROL => 0, GROUP_EXPERIMENTAL => 1 }
-
- def self.valid_subject?(subject)
- subject.is_a?(Namespace) || subject.is_a?(User) || subject.is_a?(Project)
- end
-
- private
-
- def must_have_one_subject_present
- if non_nil_subjects.length != 1
- errors.add(:base, s_("ExperimentSubject|Must have exactly one of User, Namespace, or Project."))
- end
- end
-
- def non_nil_subjects
- @non_nil_subjects ||= [user, namespace, project].reject(&:blank?)
- end
-end
diff --git a/app/models/experiment_user.rb b/app/models/experiment_user.rb
deleted file mode 100644
index e447becc1bd..00000000000
--- a/app/models/experiment_user.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# frozen_string_literal: true
-
-class ExperimentUser < ApplicationRecord
- include ::Gitlab::Experimentation::GroupTypes
-
- belongs_to :experiment
- belongs_to :user
-
- enum group_type: { GROUP_CONTROL => 0, GROUP_EXPERIMENTAL => 1 }
-
- validates :experiment_id, presence: true
- validates :user_id, presence: true
- validates :group_type, presence: true
-end
diff --git a/app/models/group.rb b/app/models/group.rb
index 38623d91705..098116ed800 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -119,6 +119,8 @@ class Group < Namespace
has_many :group_callouts, class_name: 'Users::GroupCallout', foreign_key: :group_id
+ has_many :protected_branches, inverse_of: :group
+
has_one :group_feature, inverse_of: :group, class_name: 'Groups::FeatureSetting'
delegate :prevent_sharing_groups_outside_hierarchy, :new_user_signups_cap, :setup_for_company, :jobs_to_be_done, to: :namespace_settings
@@ -465,9 +467,10 @@ class Group < Namespace
end
# Check if user is a last owner of the group.
+ # Excludes non-direct owners for top-level group
# Excludes project_bots
def last_owner?(user)
- has_owner?(user) && all_owners_excluding_project_bots.size == 1
+ has_owner?(user) && member_owners_excluding_project_bots.size == 1
end
def member_last_owner?(member)
@@ -476,8 +479,14 @@ class Group < Namespace
last_owner?(member.user)
end
- def all_owners_excluding_project_bots
- members_with_parents.owners.merge(User.without_project_bot)
+ # Excludes non-direct owners for top-level group
+ # Excludes project_bots
+ def member_owners_excluding_project_bots
+ if root?
+ members
+ else
+ members_with_parents
+ end.owners.merge(User.without_project_bot)
end
def single_blocked_owner?
@@ -487,7 +496,7 @@ class Group < Namespace
def member_last_blocked_owner?(member)
return member.last_blocked_owner unless member.last_blocked_owner.nil?
- return false if members_with_parents.owners.any?
+ return false if member_owners_excluding_project_bots.any?
single_blocked_owner? && blocked_owners.exists?(user_id: member.user)
end
@@ -1010,10 +1019,6 @@ class Group < Namespace
Arel::Nodes::SqlLiteral.new(column_alias))
end
- def self.groups_including_descendants_by(group_ids)
- Group.where(id: group_ids).self_and_descendants
- end
-
def disable_shared_runners!
update!(
shared_runners_enabled: false,
diff --git a/app/models/hooks/active_hook_filter.rb b/app/models/hooks/active_hook_filter.rb
index 283e2d680f4..cdcfd3f3ff5 100644
--- a/app/models/hooks/active_hook_filter.rb
+++ b/app/models/hooks/active_hook_filter.rb
@@ -3,14 +3,36 @@
class ActiveHookFilter
def initialize(hook)
@hook = hook
- @push_events_filter_matcher = RefMatcher.new(@hook.push_events_branch_filter)
end
def matches?(hooks_scope, data)
- return true if hooks_scope != :push_hooks
+ return true unless hooks_scope == :push_hooks
+
+ matches_branch?(data)
+ end
+
+ private
+
+ def matches_branch?(data)
return true if @hook.push_events_branch_filter.blank?
branch_name = Gitlab::Git.branch_name(data[:ref])
- @push_events_filter_matcher.matches?(branch_name)
+
+ if Feature.disabled?(:enhanced_webhook_support_regex)
+ return RefMatcher.new(@hook.push_events_branch_filter).matches?(branch_name)
+ end
+
+ case @hook.branch_filter_strategy
+ when 'all_branches'
+ true
+ when 'wildcard'
+ RefMatcher.new(@hook.push_events_branch_filter).matches?(branch_name)
+ when 'regex'
+ begin
+ Gitlab::UntrustedRegexp.new(@hook.push_events_branch_filter) === branch_name
+ rescue RegexpError
+ false
+ end
+ end
end
end
diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb
index c0073f9a9b8..3c7f0ef9ffc 100644
--- a/app/models/hooks/system_hook.rb
+++ b/app/models/hooks/system_hook.rb
@@ -10,9 +10,9 @@ class SystemHook < WebHook
:merge_request_hooks
]
- default_value_for :push_events, false
- default_value_for :repository_update_events, true
- default_value_for :merge_requests_events, false
+ attribute :push_events, default: false
+ attribute :repository_update_events, default: true
+ attribute :merge_requests_events, default: false
validates :url, system_hook_url: true
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index 71794964c99..05e50c17988 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -7,9 +7,11 @@ class WebHook < ApplicationRecord
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,
mode: :per_attribute_iv,
@@ -33,14 +35,26 @@ class WebHook < ApplicationRecord
has_many :web_hook_logs
validates :url, presence: true
- validates :url, public_url: true, unless: ->(hook) { hook.is_a?(SystemHook) }
+ validates :url, public_url: true, unless: ->(hook) { hook.is_a?(SystemHook) || hook.url_variables? }
validates :token, format: { without: /\n/ }
- validates :push_events_branch_filter, branch_filter: true
+ after_initialize :initialize_url_variables
+ before_validation :set_branch_filter_nil, \
+ if: -> { branch_filter_strategy_all_branches? && enhanced_webhook_support_regex? }
+ validates :push_events_branch_filter, \
+ untrusted_regexp: true, if: -> { branch_filter_strategy_regex? && enhanced_webhook_support_regex? }
+ validates :push_events_branch_filter, \
+ "web_hooks/wildcard_branch_filter": true, if: -> { branch_filter_strategy_wildcard? }
+
validates :url_variables, json_schema: { filename: 'web_hooks_url_variables' }
validate :no_missing_url_variables
+ validates :interpolated_url, public_url: true, if: ->(hook) { hook.url_variables? && hook.errors.empty? }
- after_initialize :initialize_url_variables
+ enum branch_filter_strategy: {
+ wildcard: 0,
+ regex: 1,
+ all_branches: 2
+ }, _prefix: true
scope :executable, -> do
next all unless Feature.enabled?(:web_hooks_disable_failed)
@@ -108,7 +122,7 @@ class WebHook < ApplicationRecord
def disable!
return if permanently_disabled?
- update_attribute(:recent_failures, FAILURE_THRESHOLD + 1)
+ update_attribute(:recent_failures, EXCEEDED_FAILURE_THRESHOLD)
end
def enable!
@@ -123,10 +137,10 @@ class WebHook < ApplicationRecord
def backoff!
return if permanently_disabled? || (backoff_count >= MAX_FAILURES && temporarily_disabled?)
- attrs = { recent_failures: recent_failures + 1 }
+ attrs = { recent_failures: next_failure_count }
if recent_failures >= FAILURE_THRESHOLD
- attrs[:backoff_count] = backoff_count.succ.clamp(1, MAX_FAILURES)
+ attrs[:backoff_count] = next_backoff_count
attrs[:disabled_until] = next_backoff.from_now
end
@@ -137,7 +151,7 @@ class WebHook < ApplicationRecord
def failed!
return unless recent_failures < MAX_FAILURES
- assign_attributes(disabled_until: nil, backoff_count: 0, recent_failures: recent_failures + 1)
+ assign_attributes(disabled_until: nil, backoff_count: 0, recent_failures: next_failure_count)
save(validate: false)
end
@@ -198,8 +212,20 @@ class WebHook < ApplicationRecord
# Overridden in child classes.
end
+ def masked_token
+ token.present? ? SECRET_MASK : nil
+ 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
+
def web_hooks_disable_failed?
self.class.web_hooks_disable_failed?(self)
end
@@ -224,4 +250,12 @@ class WebHook < ApplicationRecord
errors.add(:url, "Invalid URL template. Missing keys: #{missing}")
end
+
+ def enhanced_webhook_support_regex?
+ Feature.enabled?(:enhanced_webhook_support_regex)
+ end
+
+ def set_branch_filter_nil
+ self.push_events_branch_filter = nil
+ end
end
diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb
index c32957fbef9..2b26147b494 100644
--- a/app/models/hooks/web_hook_log.rb
+++ b/app/models/hooks/web_hook_log.rb
@@ -56,7 +56,7 @@ class WebHookLog < ApplicationRecord
def redact_user_emails
self.request_data.deep_transform_values! do |value|
- value =~ URI::MailTo::EMAIL_REGEXP ? _('[REDACTED]') : value
+ value.to_s =~ URI::MailTo::EMAIL_REGEXP ? _('[REDACTED]') : value
end
end
end
diff --git a/app/models/incident_management/timeline_event.rb b/app/models/incident_management/timeline_event.rb
index 735d4e4298c..e70209f1538 100644
--- a/app/models/incident_management/timeline_event.rb
+++ b/app/models/incident_management/timeline_event.rb
@@ -18,6 +18,8 @@ module IncidentManagement
validates :project, :incident, :occurred_at, presence: true
validates :action, presence: true, length: { maximum: 128 }
+ # `user_input` is a note filled in by a user via API. Not auto generated by GitLab
+ validates :note, presence: true, length: { maximum: 280 }, on: :user_input
validates :note, presence: true, length: { maximum: 10_000 }
validates :note_html, length: { maximum: 10_000 }
diff --git a/app/models/incident_management/timeline_event_tag.rb b/app/models/incident_management/timeline_event_tag.rb
index cde3afcaa16..d1e3fbc2a6a 100644
--- a/app/models/incident_management/timeline_event_tag.rb
+++ b/app/models/incident_management/timeline_event_tag.rb
@@ -4,6 +4,9 @@ module IncidentManagement
class TimelineEventTag < ApplicationRecord
self.table_name = 'incident_management_timeline_event_tags'
+ START_TIME_TAG_NAME = 'Start time'
+ END_TIME_TAG_NAME = 'End time'
+
belongs_to :project, inverse_of: :incident_management_timeline_event_tags
has_many :timeline_event_tag_links,
@@ -14,7 +17,13 @@ module IncidentManagement
through: :timeline_event_tag_links
validates :name, presence: true, format: { with: /\A[^,]+\z/ }
- validates :name, uniqueness: { scope: :project_id }
+ validates :name, uniqueness: { scope: :project_id, case_sensitive: false }
validates :name, length: { maximum: 255 }
+
+ scope :by_names, -> (tag_names) { where('lower(name) in (?)', tag_names.map(&:downcase)) }
+
+ def self.pluck_names
+ pluck(:name)
+ end
end
end
diff --git a/app/models/instance_metadata.rb b/app/models/instance_metadata.rb
index 6cac78178e0..47460c85671 100644
--- a/app/models/instance_metadata.rb
+++ b/app/models/instance_metadata.rb
@@ -1,11 +1,12 @@
# frozen_string_literal: true
class InstanceMetadata
- attr_reader :version, :revision, :kas
+ attr_reader :version, :revision, :kas, :enterprise
- def initialize(version: Gitlab::VERSION, revision: Gitlab.revision)
+ def initialize(version: Gitlab::VERSION, revision: Gitlab.revision, enterprise: Gitlab.ee?)
@version = version
@revision = revision
@kas = ::InstanceMetadata::Kas.new
+ @enterprise = enterprise
end
end
diff --git a/app/models/integration.rb b/app/models/integration.rb
index 23688a87cbd..41278dce22d 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -71,20 +71,20 @@ class Integration < ApplicationRecord
alias_attribute :type, :type_new
- default_value_for :active, false
- default_value_for :alert_events, true
- default_value_for :category, 'common'
- default_value_for :commit_events, true
- default_value_for :confidential_issues_events, true
- default_value_for :confidential_note_events, true
- default_value_for :issues_events, true
- default_value_for :job_events, true
- default_value_for :merge_requests_events, true
- default_value_for :note_events, true
- default_value_for :pipeline_events, true
- default_value_for :push_events, true
- default_value_for :tag_push_events, true
- default_value_for :wiki_page_events, true
+ attribute :active, default: false
+ attribute :alert_events, default: true
+ attribute :category, default: 'common'
+ attribute :commit_events, default: true
+ attribute :confidential_issues_events, default: true
+ attribute :confidential_note_events, default: true
+ attribute :issues_events, default: true
+ attribute :job_events, default: true
+ attribute :merge_requests_events, default: true
+ attribute :note_events, default: true
+ attribute :pipeline_events, default: true
+ attribute :push_events, default: true
+ attribute :tag_push_events, default: true
+ attribute :wiki_page_events, default: true
after_initialize :initialize_properties
@@ -589,6 +589,10 @@ class Integration < ApplicationRecord
false
end
+ def chat?
+ category == :chat
+ end
+
private
# Ancestors sorted by hierarchy depth in bottom-top order.
diff --git a/app/models/integrations/assembla.rb b/app/models/integrations/assembla.rb
index 88dbf2915ef..536d5584bf6 100644
--- a/app/models/integrations/assembla.rb
+++ b/app/models/integrations/assembla.rb
@@ -12,6 +12,7 @@ module Integrations
required: true
field :subdomain,
+ exposes_secrets: true,
placeholder: ''
def title
@@ -34,7 +35,9 @@ module Integrations
return unless supported_events.include?(data[:object_kind])
url = "https://atlas.assembla.com/spaces/#{subdomain}/github_tool?secret_key=#{token}"
- Gitlab::HTTP.post(url, body: { payload: data }.to_json, headers: { 'Content-Type' => 'application/json' })
+ body = { payload: data }
+
+ Gitlab::HTTP.post(url, body: Gitlab::Json.dump(body), headers: { 'Content-Type' => 'application/json' })
end
end
end
diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb
index c3a4b84bb2d..b4e97f0871e 100644
--- a/app/models/integrations/bamboo.rb
+++ b/app/models/integrations/bamboo.rb
@@ -9,6 +9,7 @@ module Integrations
title: -> { s_('BambooService|Bamboo URL') },
placeholder: -> { s_('https://bamboo.example.com') },
help: -> { s_('BambooService|Bamboo service root URL.') },
+ exposes_secrets: true,
required: true
field :build_key,
@@ -37,14 +38,6 @@ module Integrations
attr_accessor :response
- before_validation :reset_password
-
- def reset_password
- if bamboo_url_changed? && !password_touched?
- self.password = nil
- end
- end
-
def title
s_('BambooService|Atlassian Bamboo')
end
diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb
index c7992e4083c..750aa60b185 100644
--- a/app/models/integrations/base_chat_notification.rb
+++ b/app/models/integrations/base_chat_notification.rb
@@ -22,7 +22,9 @@ module Integrations
MATCH_ALL_LABELS = 'match_all'
].freeze
- default_value_for :category, 'chat'
+ SECRET_MASK = '************'
+
+ attribute :category, default: 'chat'
prop_accessor :webhook, :username, :channel, :branches_to_be_notified, :labels_to_be_notified, :labels_to_be_notified_behavior
@@ -71,7 +73,7 @@ module Integrations
def default_fields
[
- { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}", required: true }.freeze,
+ { type: 'text', name: 'webhook', help: "#{webhook_help}", required: true }.freeze,
{ type: 'text', name: 'username', placeholder: 'GitLab-integration' }.freeze,
{ type: 'checkbox', name: 'notify_only_broken_pipelines', help: 'Do not send notifications for successful pipelines.' }.freeze,
{
@@ -147,7 +149,7 @@ module Integrations
raise NotImplementedError
end
- def webhook_placeholder
+ def webhook_help
raise NotImplementedError
end
diff --git a/app/models/integrations/base_ci.rb b/app/models/integrations/base_ci.rb
index 4f8732da703..db29f228e60 100644
--- a/app/models/integrations/base_ci.rb
+++ b/app/models/integrations/base_ci.rb
@@ -5,7 +5,7 @@
# working with GitLab merge requests
module Integrations
class BaseCi < Integration
- default_value_for :category, 'ci'
+ attribute :category, default: 'ci'
def valid_token?(token)
self.respond_to?(:token) && self.token.present? && ActiveSupport::SecurityUtils.secure_compare(token, self.token)
diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb
index a4cec5f927b..e0994305e9d 100644
--- a/app/models/integrations/base_issue_tracker.rb
+++ b/app/models/integrations/base_issue_tracker.rb
@@ -4,7 +4,7 @@ module Integrations
class BaseIssueTracker < Integration
validate :one_issue_tracker, if: :activated?, on: :manual_change
- default_value_for :category, 'issue_tracker'
+ attribute :category, default: 'issue_tracker'
before_validation :handle_properties
before_validation :set_default_data, on: :create
diff --git a/app/models/integrations/base_monitoring.rb b/app/models/integrations/base_monitoring.rb
index 280eeda7c6c..b0bebb5a859 100644
--- a/app/models/integrations/base_monitoring.rb
+++ b/app/models/integrations/base_monitoring.rb
@@ -6,7 +6,7 @@
# to provide additional features for environments.
module Integrations
class BaseMonitoring < Integration
- default_value_for :category, 'monitoring'
+ attribute :category, default: 'monitoring'
def self.supported_events
%w()
diff --git a/app/models/integrations/base_slack_notification.rb b/app/models/integrations/base_slack_notification.rb
new file mode 100644
index 00000000000..cb785afdcfe
--- /dev/null
+++ b/app/models/integrations/base_slack_notification.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+module Integrations
+ class BaseSlackNotification < BaseChatNotification
+ SUPPORTED_EVENTS_FOR_USAGE_LOG = %w[
+ push issue confidential_issue merge_request note confidential_note tag_push wiki_page deployment
+ ].freeze
+
+ prop_accessor EVENT_CHANNEL['alert']
+
+ override :default_channel_placeholder
+ def default_channel_placeholder
+ _('#general, #development')
+ end
+
+ override :get_message
+ def get_message(object_kind, data)
+ return Integrations::ChatMessage::AlertMessage.new(data) if object_kind == 'alert'
+
+ super
+ end
+
+ override :supported_events
+ def supported_events
+ additional = ['alert']
+
+ super + additional
+ end
+
+ override :configurable_channels?
+ def configurable_channels?
+ true
+ end
+
+ override :log_usage
+ def log_usage(event, user_id)
+ return unless user_id
+
+ return unless SUPPORTED_EVENTS_FOR_USAGE_LOG.include?(event)
+
+ key = "i_ecosystem_slack_service_#{event}_notification"
+
+ 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
+ }.compact
+
+ Gitlab::Tracking.event(
+ self.class.name,
+ Integration::SNOWPLOW_EVENT_ACTION,
+ label: Integration::SNOWPLOW_EVENT_LABEL,
+ property: key,
+ user: User.find(user_id),
+ **optional_arguments
+ )
+ end
+ end
+end
diff --git a/app/models/integrations/base_slash_commands.rb b/app/models/integrations/base_slash_commands.rb
index e51d748b562..314f0a6ee5d 100644
--- a/app/models/integrations/base_slash_commands.rb
+++ b/app/models/integrations/base_slash_commands.rb
@@ -4,7 +4,7 @@
# This class is not meant to be used directly, but only to inherrit from.
module Integrations
class BaseSlashCommands < Integration
- default_value_for :category, 'chat'
+ attribute :category, default: 'chat'
prop_accessor :token
diff --git a/app/models/integrations/base_third_party_wiki.rb b/app/models/integrations/base_third_party_wiki.rb
index 24f5bec93cf..8df172e9a53 100644
--- a/app/models/integrations/base_third_party_wiki.rb
+++ b/app/models/integrations/base_third_party_wiki.rb
@@ -2,7 +2,7 @@
module Integrations
class BaseThirdPartyWiki < Integration
- default_value_for :category, 'third_party_wiki'
+ attribute :category, default: 'third_party_wiki'
validate :only_one_third_party_wiki, if: :activated?, on: :manual_change
diff --git a/app/models/integrations/buildkite.rb b/app/models/integrations/buildkite.rb
index f2d2aca3ffe..5c08eac8557 100644
--- a/app/models/integrations/buildkite.rb
+++ b/app/models/integrations/buildkite.rb
@@ -6,13 +6,13 @@ module Integrations
class Buildkite < BaseCi
include HasWebHook
include ReactivelyCached
- extend Gitlab::Utils::Override
ENDPOINT = "https://buildkite.com"
field :project_url,
title: -> { _('Pipeline URL') },
placeholder: "#{ENDPOINT}/example-org/test-pipeline",
+ exposes_secrets: true,
required: true
field :token,
diff --git a/app/models/integrations/chat_message/pipeline_message.rb b/app/models/integrations/chat_message/pipeline_message.rb
index b3502905bf7..88db40bea7f 100644
--- a/app/models/integrations/chat_message/pipeline_message.rb
+++ b/app/models/integrations/chat_message/pipeline_message.rb
@@ -126,6 +126,14 @@ module Integrations
}
end
+ def pipeline_name_field
+ {
+ title: s_("ChatMessage|Pipeline name"),
+ value: pipeline.name,
+ short: false
+ }
+ end
+
def attachments_fields
fields = [
{
@@ -143,6 +151,7 @@ module Integrations
fields << failed_stages_field if failed_stages.any?
fields << failed_jobs_field if failed_jobs.any?
fields << yaml_error_field if pipeline.has_yaml_errors?
+ fields << pipeline_name_field if Feature.enabled?(:pipeline_name, project) && pipeline.name.present?
fields
end
diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb
index ab0fdbd777f..27bed5d3f76 100644
--- a/app/models/integrations/datadog.rb
+++ b/app/models/integrations/datadog.rb
@@ -3,7 +3,6 @@
module Integrations
class Datadog < Integration
include HasWebHook
- extend Gitlab::Utils::Override
DEFAULT_DOMAIN = 'datadoghq.com'
URL_TEMPLATE = 'https://webhook-intake.%{datadog_domain}/api/v2/webhook'
@@ -91,7 +90,7 @@ module Integrations
with_options if: :activated? do
validates :api_key, presence: true, format: { with: /\A\w+\z/ }
- validates :datadog_site, format: { with: /\A[\w\.]+\z/, allow_blank: true }
+ validates :datadog_site, format: { with: %r{\A\w+([-.]\w+)*\.[a-zA-Z]{2,5}(:[0-9]{1,5})?\z}, allow_blank: true }
validates :api_url, public_url: { allow_blank: true }
validates :datadog_site, presence: true, unless: -> (obj) { obj.api_url.present? }
validates :api_url, presence: true, unless: -> (obj) { obj.datadog_site.present? }
@@ -169,8 +168,8 @@ module Integrations
result = execute(data)
{
- success: (200..299).cover?(result[:http_status]),
- result: result[:message]
+ success: (200..299).cover?(result.payload[:http_status]),
+ result: result.message
}
end
diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb
index d0389b82410..061c491034d 100644
--- a/app/models/integrations/discord.rb
+++ b/app/models/integrations/discord.rb
@@ -10,8 +10,7 @@ module Integrations
field :webhook,
section: SECTION_TYPE_CONNECTION,
- placeholder: 'https://discordapp.com/api/webhooks/…',
- help: 'URL to the webhook for the Discord channel.',
+ help: 'e.g. https://discordapp.com/api/webhooks/…',
required: true
field :notify_only_broken_pipelines,
diff --git a/app/models/integrations/drone_ci.rb b/app/models/integrations/drone_ci.rb
index d1a64aa96d4..781acf65c47 100644
--- a/app/models/integrations/drone_ci.rb
+++ b/app/models/integrations/drone_ci.rb
@@ -6,7 +6,6 @@ module Integrations
include PushDataValidations
include ReactivelyCached
prepend EnableSslVerification
- extend Gitlab::Utils::Override
DRONE_SAAS_HOSTNAME = 'cloud.drone.io'
diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb
index 6e7f31aa030..c903e8d9eb8 100644
--- a/app/models/integrations/hangouts_chat.rb
+++ b/app/models/integrations/hangouts_chat.rb
@@ -22,10 +22,6 @@ module Integrations
def default_channel_placeholder
end
- def webhook_placeholder
- 'https://chat.googleapis.com/v1/spaces…'
- end
-
def self.supported_events
%w[push issue confidential_issue merge_request note confidential_note tag_push
pipeline wiki_page]
@@ -33,7 +29,7 @@ module Integrations
def default_fields
[
- { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}" },
+ { type: 'text', name: 'webhook', help: 'https://chat.googleapis.com/v1/spaces…' },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
{
type: 'select',
diff --git a/app/models/integrations/jenkins.rb b/app/models/integrations/jenkins.rb
index 74a6449f4f9..d2e8393ef95 100644
--- a/app/models/integrations/jenkins.rb
+++ b/app/models/integrations/jenkins.rb
@@ -5,10 +5,10 @@ module Integrations
include HasWebHook
prepend EnableSslVerification
- extend Gitlab::Utils::Override
field :jenkins_url,
title: -> { s_('ProjectService|Jenkins server URL') },
+ exposes_secrets: true,
required: true,
placeholder: 'http://jenkins.example.com',
help: -> { s_('The URL of the Jenkins server.') }
@@ -27,21 +27,13 @@ module Integrations
non_empty_password_title: -> { s_('ProjectService|Enter new password.') },
non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current password.') }
- before_validation :reset_password
-
validates :jenkins_url, presence: true, addressable_url: true, if: :activated?
validates :project_name, presence: true, if: :activated?
validates :username, presence: true, if: ->(service) { service.activated? && service.password_touched? && service.password.present? }
+ validates :password, presence: true, if: ->(service) { service.activated? && service.username.present? }
- default_value_for :merge_requests_events, false
- default_value_for :tag_push_events, false
-
- def reset_password
- # don't reset the password if a new one is provided
- if (jenkins_url_changed? || username.blank?) && !password_touched?
- self.password = nil
- end
- end
+ attribute :merge_requests_events, default: false
+ attribute :tag_push_events, default: false
def execute(data)
return unless supported_events.include?(data[:object_kind])
@@ -52,12 +44,12 @@ module Integrations
def test(data)
begin
result = execute(data)
- return { success: false, result: result[:message] } if result[:http_status] != 200
+ return { success: false, result: result.message } if result.payload[:http_status] != 200
rescue StandardError => e
return { success: false, result: e }
end
- { success: true, result: result[:message] }
+ { success: true, result: result.message }
end
override :hook_url
diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb
index 3ca514ab1fd..30497c0110e 100644
--- a/app/models/integrations/jira.rb
+++ b/app/models/integrations/jira.rb
@@ -3,7 +3,6 @@
# Accessible as Project#external_issue_tracker
module Integrations
class Jira < BaseIssueTracker
- extend ::Gitlab::Utils::Override
include Gitlab::Routing
include ApplicationHelper
include ActionView::Helpers::AssetUrlHelper
@@ -533,13 +532,14 @@ module Integrations
end
def build_entity_meta(entity)
- if entity.is_a?(Commit)
+ case entity
+ when Commit
{
id: entity.short_id,
description: entity.safe_message,
branch: branch_name(entity)
}
- elsif entity.is_a?(MergeRequest)
+ when MergeRequest
{
id: entity.to_reference,
branch: entity.source_branch
diff --git a/app/models/integrations/mattermost.rb b/app/models/integrations/mattermost.rb
index dae11b99bc5..dd1c98ee06b 100644
--- a/app/models/integrations/mattermost.rb
+++ b/app/models/integrations/mattermost.rb
@@ -3,7 +3,6 @@
module Integrations
class Mattermost < BaseChatNotification
include SlackMattermostNotifier
- extend ::Gitlab::Utils::Override
def title
s_('Mattermost notifications')
@@ -26,7 +25,7 @@ module Integrations
'my-channel'
end
- def webhook_placeholder
+ def webhook_help
'http://mattermost.example.com/hooks/'
end
diff --git a/app/models/integrations/microsoft_teams.rb b/app/models/integrations/microsoft_teams.rb
index 69863f164cd..d6cbe5760e8 100644
--- a/app/models/integrations/microsoft_teams.rb
+++ b/app/models/integrations/microsoft_teams.rb
@@ -18,10 +18,6 @@ module Integrations
'<p>Use this service to send notifications about events in GitLab projects to your Microsoft Teams channels. <a href="https://docs.gitlab.com/ee/user/project/integrations/microsoft_teams.html" target="_blank" rel="noopener noreferrer">How do I configure this integration?</a></p>'
end
- def webhook_placeholder
- 'https://outlook.office.com/webhook/…'
- end
-
def default_channel_placeholder
end
@@ -32,7 +28,7 @@ module Integrations
def default_fields
[
- { type: 'text', section: SECTION_TYPE_CONNECTION, name: 'webhook', required: true, placeholder: "#{webhook_placeholder}" },
+ { type: 'text', section: SECTION_TYPE_CONNECTION, name: 'webhook', help: 'https://outlook.office.com/webhook/…', required: true },
{
type: 'checkbox',
section: SECTION_TYPE_CONFIGURATION,
diff --git a/app/models/integrations/packagist.rb b/app/models/integrations/packagist.rb
index 7177c82a167..7148de66aee 100644
--- a/app/models/integrations/packagist.rb
+++ b/app/models/integrations/packagist.rb
@@ -3,7 +3,6 @@
module Integrations
class Packagist < Integration
include HasWebHook
- extend Gitlab::Utils::Override
field :username,
title: -> { s_('Username') },
@@ -55,12 +54,12 @@ module Integrations
def test(data)
begin
result = execute(data)
- return { success: false, result: result[:message] } if result[:http_status] != 202
+ return { success: false, result: result.message } if result.payload[:http_status] != 202
rescue StandardError => e
- return { success: false, result: e }
+ return { success: false, result: e.message }
end
- { success: true, result: result[:message] }
+ { success: true, result: result.message }
end
override :hook_url
diff --git a/app/models/integrations/pivotaltracker.rb b/app/models/integrations/pivotaltracker.rb
index d32fb974339..1acdbbbf9bc 100644
--- a/app/models/integrations/pivotaltracker.rb
+++ b/app/models/integrations/pivotaltracker.rb
@@ -56,7 +56,7 @@ module Integrations
}
Gitlab::HTTP.post(
API_ENDPOINT,
- body: message.to_json,
+ body: Gitlab::Json.dump(message),
headers: {
'Content-Type' => 'application/json',
'X-TrackerToken' => token
diff --git a/app/models/integrations/pumble.rb b/app/models/integrations/pumble.rb
index 17026410eb1..e08dc6d0f51 100644
--- a/app/models/integrations/pumble.rb
+++ b/app/models/integrations/pumble.rb
@@ -36,7 +36,7 @@ module Integrations
def default_fields
[
- { type: 'text', name: 'webhook', placeholder: "https://api.pumble.com/workspaces/x/...", required: true },
+ { type: 'text', name: 'webhook', help: 'https://api.pumble.com/workspaces/x/...', required: true },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
{
type: 'select',
@@ -51,7 +51,7 @@ module Integrations
def notify(message, opts)
header = { 'Content-Type' => 'application/json' }
- response = Gitlab::HTTP.post(webhook, headers: header, body: { text: message.summary }.to_json)
+ response = Gitlab::HTTP.post(webhook, headers: header, body: Gitlab::Json.dump({ text: message.summary }))
response if response.success?
end
diff --git a/app/models/integrations/slack.rb b/app/models/integrations/slack.rb
index c254ea379bb..89326b8174f 100644
--- a/app/models/integrations/slack.rb
+++ b/app/models/integrations/slack.rb
@@ -1,17 +1,8 @@
# frozen_string_literal: true
module Integrations
- class Slack < BaseChatNotification
+ class Slack < BaseSlackNotification
include SlackMattermostNotifier
- extend ::Gitlab::Utils::Override
-
- SUPPORTED_EVENTS_FOR_USAGE_LOG = %w[
- push issue confidential_issue merge_request note confidential_note
- tag_push wiki_page deployment
- ].freeze
- SNOWPLOW_EVENT_CATEGORY = self.name
-
- prop_accessor EVENT_CHANNEL['alert']
def title
'Slack notifications'
@@ -25,57 +16,9 @@ module Integrations
'slack'
end
- def default_channel_placeholder
- _('#general, #development')
- end
-
- def webhook_placeholder
+ override :webhook_help
+ def webhook_help
'https://hooks.slack.com/services/…'
end
-
- def supported_events
- additional = []
- additional << 'alert'
-
- super + additional
- end
-
- def get_message(object_kind, data)
- return Integrations::ChatMessage::AlertMessage.new(data) if object_kind == 'alert'
-
- super
- end
-
- override :log_usage
- def log_usage(event, user_id)
- return unless user_id
-
- return unless SUPPORTED_EVENTS_FOR_USAGE_LOG.include?(event)
-
- key = "i_ecosystem_slack_service_#{event}_notification"
-
- 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
- }.compact
-
- Gitlab::Tracking.event(
- SNOWPLOW_EVENT_CATEGORY,
- Integration::SNOWPLOW_EVENT_ACTION,
- label: Integration::SNOWPLOW_EVENT_LABEL,
- property: key,
- user: User.find(user_id),
- **optional_arguments
- )
- end
-
- override :configurable_channels?
- def configurable_channels?
- true
- end
end
end
diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb
index ca7a715f4b3..af629d6ef1e 100644
--- a/app/models/integrations/teamcity.rb
+++ b/app/models/integrations/teamcity.rb
@@ -11,6 +11,7 @@ module Integrations
field :teamcity_url,
title: -> { s_('ProjectService|TeamCity server URL') },
placeholder: 'https://teamcity.example.com',
+ exposes_secrets: true,
required: true
field :build_type,
@@ -36,8 +37,6 @@ module Integrations
attr_accessor :response
- before_validation :reset_password
-
class << self
def to_param
'teamcity'
@@ -48,12 +47,6 @@ module Integrations
end
end
- def reset_password
- if teamcity_url_changed? && !password_touched?
- self.password = nil
- end
- end
-
def title
'JetBrains TeamCity'
end
diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb
index f10a75fac5d..aa19133b8c2 100644
--- a/app/models/integrations/unify_circuit.rb
+++ b/app/models/integrations/unify_circuit.rb
@@ -29,7 +29,7 @@ module Integrations
def default_fields
[
- { type: 'text', name: 'webhook', placeholder: "https://yourcircuit.com/rest/v2/webhooks/incoming/…", required: true },
+ { type: 'text', name: 'webhook', help: 'https://yourcircuit.com/rest/v2/webhooks/incoming/…', required: true },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
{
type: 'select',
@@ -43,11 +43,13 @@ module Integrations
private
def notify(message, opts)
- response = Gitlab::HTTP.post(webhook, body: {
+ body = {
subject: message.project_name,
text: message.summary,
markdown: true
- }.to_json)
+ }
+
+ response = Gitlab::HTTP.post(webhook, body: Gitlab::Json.dump(body))
response if response.success?
end
diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb
index 75be457dcf5..8e6f5ca6d17 100644
--- a/app/models/integrations/webex_teams.rb
+++ b/app/models/integrations/webex_teams.rb
@@ -29,7 +29,7 @@ module Integrations
def default_fields
[
- { type: 'text', name: 'webhook', placeholder: "https://api.ciscospark.com/v1/webhooks/incoming/...", required: true },
+ { type: 'text', name: 'webhook', help: 'https://api.ciscospark.com/v1/webhooks/incoming/...', required: true },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
{
type: 'select',
@@ -44,7 +44,7 @@ module Integrations
def notify(message, opts)
header = { 'Content-Type' => 'application/json' }
- response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.summary }.to_json)
+ response = Gitlab::HTTP.post(webhook, headers: header, body: Gitlab::Json.dump({ markdown: message.summary }))
response if response.success?
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index ea7acf9a5d1..fc083002c41 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -39,13 +39,14 @@ class Issue < ApplicationRecord
DueNextMonthAndPreviousTwoWeeks = DueDateStruct.new('Due Next Month And Previous Two Weeks', 'next_month_and_previous_two_weeks').freeze
SORTING_PREFERENCE_FIELD = :issues_sort
+ MAX_BRANCH_TEMPLATE = 255
# Types of issues that should be displayed on issue lists across the app
# for example, project issues list, group issues list, and issues dashboard.
#
# This should be kept consistent with the enums used for the GraphQL issue list query in
# https://gitlab.com/gitlab-org/gitlab/-/blob/1379c2d7bffe2a8d809f23ac5ef9b4114f789c07/app/assets/javascripts/issues/list/constants.js#L154-158
- TYPES_FOR_LIST = %w(issue incident test_case task).freeze
+ TYPES_FOR_LIST = %w(issue incident test_case task objective).freeze
# Types of issues that should be displayed on issue board lists
TYPES_FOR_BOARD_LIST = %w(issue incident).freeze
@@ -90,6 +91,7 @@ class Issue < ApplicationRecord
has_one :incident_management_issuable_escalation_status, class_name: 'IncidentManagement::IssuableEscalationStatus'
has_and_belongs_to_many :self_managed_prometheus_alert_events, join_table: :issues_self_managed_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany
has_and_belongs_to_many :prometheus_alert_events, join_table: :issues_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany
+ has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :issue
has_many :prometheus_alerts, through: :prometheus_alert_events
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
@@ -210,6 +212,7 @@ class Issue < ApplicationRecord
end
scope :with_null_relative_position, -> { where(relative_position: nil) }
scope :with_non_null_relative_position, -> { where.not(relative_position: nil) }
+ scope :with_projects_matching_search_data, -> { where('issue_search_data.project_id = issues.project_id') }
before_validation :ensure_namespace_id, :ensure_work_item_type
@@ -270,9 +273,14 @@ class Issue < ApplicationRecord
reorder(upvotes_count: :asc)
end
- override :pg_full_text_search
- def pg_full_text_search(search_term)
- super.where('issue_search_data.project_id = issues.project_id')
+ override :full_search
+ def full_search(query, matched_columns: nil, use_minimum_char_limit: true)
+ return super if query.match?(IssuableFinder::FULL_TEXT_SEARCH_TERM_REGEX)
+
+ super.where(
+ 'issues.title NOT SIMILAR TO :pattern OR issues.description NOT SIMILAR TO :pattern',
+ pattern: IssuableFinder::FULL_TEXT_SEARCH_TERM_PATTERN
+ )
end
end
@@ -393,10 +401,21 @@ class Issue < ApplicationRecord
)
end
- def self.to_branch_name(*args)
- branch_name = args.map(&:to_s).each_with_index.map do |arg, i|
- arg.parameterize(preserve_case: i == 0).presence
- end.compact.join('-')
+ def self.to_branch_name(id, title, project: nil)
+ params = {
+ 'id' => id.to_s.parameterize(preserve_case: true),
+ 'title' => title.to_s.parameterize
+ }
+ template = project&.issue_branch_template
+
+ branch_name =
+ if template.present?
+ Gitlab::StringPlaceholderReplacer.replace_string_placeholders(template, /(#{params.keys.join('|')})/) do |arg|
+ params[arg]
+ end
+ else
+ params.values.select(&:present?).join('-')
+ end
if branch_name.length > 100
truncated_string = branch_name[0, 100]
@@ -474,7 +493,7 @@ class Issue < ApplicationRecord
if self.confidential?
"#{iid}-confidential-issue"
else
- self.class.to_branch_name(iid, title)
+ self.class.to_branch_name(iid, title, project: project)
end
end
@@ -653,6 +672,10 @@ class Issue < ApplicationRecord
Gitlab::EtagCaching::Store.new.touch(key)
end
+ def supports_confidentiality?
+ true
+ end
+
private
def due_date_after_start_date
diff --git a/app/models/iteration.rb b/app/models/iteration.rb
index ed73793c78f..c6269313d8b 100644
--- a/app/models/iteration.rb
+++ b/app/models/iteration.rb
@@ -4,9 +4,8 @@
class Iteration < ApplicationRecord
include IgnorableColumns
- # TODO https://gitlab.com/gitlab-org/gitlab/-/issues/372125
# TODO https://gitlab.com/gitlab-org/gitlab/-/issues/372126
- ignore_column :project_id, remove_with: '15.6', remove_after: '2022-09-17'
+ ignore_column :project_id, remove_with: '15.7', remove_after: '2022-11-18'
self.table_name = 'sprints'
diff --git a/app/models/label.rb b/app/models/label.rb
index 483d51099b1..aa53c0e0f3f 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -14,8 +14,7 @@ class Label < ApplicationRecord
DEFAULT_COLOR = ::Gitlab::Color.of('#6699cc')
- attribute :color, ::Gitlab::Database::Type::Color.new
- default_value_for :color, DEFAULT_COLOR
+ attribute :color, ::Gitlab::Database::Type::Color.new, default: DEFAULT_COLOR
has_many :lists, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :priorities, class_name: 'LabelPriority'
diff --git a/app/models/member.rb b/app/models/member.rb
index ff1d8f18c25..80c5fd7e468 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -286,7 +286,7 @@ class Member < ApplicationRecord
refresh_member_authorized_projects(blocking: false)
end
- default_value_for :notification_level, NotificationSetting.levels[:global]
+ attribute :notification_level, default: -> { NotificationSetting.levels[:global] }
class << self
def search(query)
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index 2b35f7da7b4..ad1ad1e74fe 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -13,7 +13,7 @@ class GroupMember < Member
delegate :update_two_factor_requirement, to: :user, allow_nil: true
# Make sure group member points only to group as it source
- default_value_for :source_type, SOURCE_TYPE
+ attribute :source_type, default: SOURCE_TYPE
validates :source_type, format: { with: SOURCE_TYPE_FORMAT }
default_scope { where(source_type: SOURCE_TYPE) } # rubocop:disable Cop/DefaultScope
diff --git a/app/models/members/last_group_owner_assigner.rb b/app/models/members/last_group_owner_assigner.rb
index e411a0ef5eb..48c9bcb9a70 100644
--- a/app/models/members/last_group_owner_assigner.rb
+++ b/app/models/members/last_group_owner_assigner.rb
@@ -40,6 +40,6 @@ class LastGroupOwnerAssigner
end
def owners
- @owners ||= group.all_owners_excluding_project_bots.load
+ @owners ||= group.member_owners_excluding_project_bots.load
end
end
diff --git a/app/models/members/member_task.rb b/app/models/members/member_task.rb
index f093619ff36..6cf6b1adb45 100644
--- a/app/models/members/member_task.rb
+++ b/app/models/members/member_task.rb
@@ -34,9 +34,10 @@ class MemberTask < ApplicationRecord
end
def project_in_member_source
- if member.is_a?(GroupMember)
+ case member
+ when GroupMember
errors.add(:project, _('is not in the member group')) unless project.namespace == member.source
- elsif member.is_a?(ProjectMember)
+ when ProjectMember
errors.add(:project, _('is not the member project')) unless project == member.source
end
end
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 8fd82fcb34a..1099e0f48c0 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -10,7 +10,7 @@ class ProjectMember < Member
delegate :namespace_id, to: :project
# Make sure project member points only to project as it source
- default_value_for :source_type, SOURCE_TYPE
+ attribute :source_type, default: SOURCE_TYPE
validates :source_type, format: { with: SOURCE_TYPE_FORMAT }
default_scope { where(source_type: SOURCE_TYPE) } # rubocop:disable Cop/DefaultScope
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index fb20d91fa20..735c0df1529 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -136,7 +136,7 @@ class MergeRequest < ApplicationRecord
before_validation :set_draft_status
- after_create :ensure_merge_request_diff
+ after_create :ensure_merge_request_diff, unless: :skip_ensure_merge_request_diff
after_update :clear_memoized_shas
after_update :reload_diff_if_branch_changed
after_commit :ensure_metrics, on: [:create, :update], unless: :importing?
@@ -146,6 +146,10 @@ class MergeRequest < ApplicationRecord
# It allows us to close or modify broken merge requests
attr_accessor :allow_broken
+ # Temporary flag to skip merge_request_diff creation on create.
+ # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/100390
+ attr_accessor :skip_ensure_merge_request_diff
+
# Temporary fields to store compare vars
# when creating new merge request
attr_accessor :can_be_created, :compare_commits, :diff_options, :compare
@@ -242,9 +246,7 @@ class MergeRequest < ApplicationRecord
end
after_transition any => [:unchecked, :cannot_be_merged_recheck, :checking, :cannot_be_merged_rechecking, :can_be_merged, :cannot_be_merged] do |merge_request, transition|
- if Feature.enabled?(:trigger_mr_subscription_on_merge_status_change, merge_request.project)
- GraphqlTriggers.merge_request_merge_status_updated(merge_request)
- end
+ GraphqlTriggers.merge_request_merge_status_updated(merge_request)
end
# rubocop: disable CodeReuse/ServiceClass
@@ -649,8 +651,8 @@ class MergeRequest < ApplicationRecord
context_commits.count
end
- def commits(limit: nil, load_from_gitaly: false)
- return merge_request_diff.commits(limit: limit, load_from_gitaly: load_from_gitaly) if merge_request_diff.persisted?
+ def commits(limit: nil, load_from_gitaly: false, page: nil)
+ return merge_request_diff.commits(limit: limit, load_from_gitaly: load_from_gitaly, page: page) if merge_request_diff.persisted?
commits_arr = if compare_commits
reversed_commits = compare_commits.reverse
@@ -662,8 +664,8 @@ class MergeRequest < ApplicationRecord
CommitCollection.new(source_project, commits_arr, source_branch)
end
- def recent_commits(load_from_gitaly: false)
- commits(limit: MergeRequestDiff::COMMITS_SAFE_SIZE, load_from_gitaly: load_from_gitaly)
+ def recent_commits(limit: MergeRequestDiff::COMMITS_SAFE_SIZE, load_from_gitaly: false, page: nil)
+ commits(limit: limit, load_from_gitaly: load_from_gitaly, page: page)
end
def commits_count
@@ -1130,7 +1132,7 @@ class MergeRequest < ApplicationRecord
# rubocop: enable CodeReuse/ServiceClass
def diffable_merge_ref?
- open? && merge_head_diff.present? && (Feature.enabled?(:display_merge_conflicts_in_diff, project) || can_be_merged?)
+ open? && merge_head_diff.present? && can_be_merged?
end
# Returns boolean indicating the merge_status should be rechecked in order to
@@ -1673,7 +1675,7 @@ class MergeRequest < ApplicationRecord
# TODO: consider renaming this as with exposed artifacts we generate reports,
# not always compare
# issue: https://gitlab.com/gitlab-org/gitlab/issues/34224
- def compare_reports(service_class, current_user = nil, report_type = nil, additional_params = {} )
+ 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)
diff --git a/app/models/merge_request_assignee.rb b/app/models/merge_request_assignee.rb
index be3a1d42eac..3e481e35deb 100644
--- a/app/models/merge_request_assignee.rb
+++ b/app/models/merge_request_assignee.rb
@@ -1,9 +1,6 @@
# frozen_string_literal: true
class MergeRequestAssignee < ApplicationRecord
- include IgnorableColumns
- ignore_column %i[state updated_state_by_user_id], remove_with: '15.6', remove_after: '2022-10-22'
-
belongs_to :merge_request, touch: true
belongs_to :assignee, class_name: "User", foreign_key: :user_id, inverse_of: :merge_request_assignees
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 9f7e98dc04b..98a9ccc2040 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -292,9 +292,9 @@ class MergeRequestDiff < ApplicationRecord
end
end
- def commits(limit: nil, load_from_gitaly: false)
- strong_memoize(:"commits_#{limit || 'all'}_#{load_from_gitaly}") do
- load_commits(limit: limit, load_from_gitaly: load_from_gitaly)
+ def commits(limit: nil, load_from_gitaly: false, page: nil)
+ strong_memoize(:"commits_#{limit || 'all'}_#{load_from_gitaly}_page_#{page}") do
+ load_commits(limit: limit, load_from_gitaly: load_from_gitaly, page: page)
end
end
@@ -725,17 +725,19 @@ class MergeRequestDiff < ApplicationRecord
end
end
- def load_commits(limit: nil, load_from_gitaly: false)
+ def load_commits(limit: nil, load_from_gitaly: false, page: nil)
+ diff_commits = page.present? ? merge_request_diff_commits.page(page).per(limit) : merge_request_diff_commits.limit(limit)
+
if load_from_gitaly
- commits = Gitlab::Git::Commit.batch_by_oid(repository, merge_request_diff_commits.limit(limit).map(&:sha))
+ commits = Gitlab::Git::Commit.batch_by_oid(repository, diff_commits.map(&:sha))
commits = Commit.decorate(commits, project)
else
- commits = merge_request_diff_commits.with_users.limit(limit)
+ commits = diff_commits.with_users
.map { |commit| Commit.from_hash(commit.to_hash, project) }
end
CommitCollection
- .new(merge_request.target_project, commits, merge_request.target_branch)
+ .new(merge_request.target_project, commits, merge_request.target_branch, page: page.to_i, per_page: limit, count: commits_count)
end
def save_diffs
diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb
index 66f1e45fd49..152fb195c97 100644
--- a/app/models/merge_request_diff_commit.rb
+++ b/app/models/merge_request_diff_commit.rb
@@ -70,7 +70,7 @@ class MergeRequestDiffCommit < ApplicationRecord
sha: Gitlab::Database::ShaAttribute.serialize(sha), # rubocop:disable Cop/ActiveRecordSerialize
authored_date: Gitlab::Database.sanitize_timestamp(commit_hash[:authored_date]),
committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date]),
- trailers: commit_hash.fetch(:trailers, {}).to_json
+ trailers: Gitlab::Json.dump(commit_hash.fetch(:trailers, {}))
)
end
diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb
index 04b322ef5a6..5a98131a6fd 100644
--- a/app/models/merge_request_diff_file.rb
+++ b/app/models/merge_request_diff_file.rb
@@ -15,12 +15,7 @@ class MergeRequestDiffFile < ApplicationRecord
end
def utf8_diff
- fetched_diff = if Feature.enabled?(:externally_stored_diffs_caching_export) &&
- merge_request_diff&.stored_externally?
- diff_export
- else
- diff
- end
+ fetched_diff = merge_request_diff&.stored_externally? ? diff_export : diff
return '' if fetched_diff.blank?
diff --git a/app/models/merge_request_reviewer.rb b/app/models/merge_request_reviewer.rb
index 4b5b71481d3..e1e2805cd8f 100644
--- a/app/models/merge_request_reviewer.rb
+++ b/app/models/merge_request_reviewer.rb
@@ -2,8 +2,7 @@
class MergeRequestReviewer < ApplicationRecord
include MergeRequestReviewerState
- include IgnorableColumns
- ignore_column :updated_state_by_user_id, remove_with: '15.6', remove_after: '2022-10-22'
+ include BulkInsertSafe # must be included _last_ i.e. after any other concerns
belongs_to :merge_request
belongs_to :reviewer, class_name: 'User', foreign_key: :user_id, inverse_of: :merge_request_reviewers
diff --git a/app/models/ml/candidate.rb b/app/models/ml/candidate.rb
index 29e1ba88528..f7da4418624 100644
--- a/app/models/ml/candidate.rb
+++ b/app/models/ml/candidate.rb
@@ -11,8 +11,15 @@ module Ml
belongs_to :user
has_many :metrics, class_name: 'Ml::CandidateMetric'
has_many :params, class_name: 'Ml::CandidateParam'
+ has_many :latest_metrics, -> { latest }, class_name: 'Ml::CandidateMetric', inverse_of: :candidate
- default_value_for(:iid) { SecureRandom.uuid }
+ attribute :iid, default: -> { SecureRandom.uuid }
+
+ scope :including_metrics_and_params, -> { includes(:latest_metrics, :params) }
+
+ def artifact_root
+ "/ml_candidate_#{iid}/-/"
+ end
class << self
def with_project_id_and_iid(project_id, iid)
diff --git a/app/models/ml/candidate_metric.rb b/app/models/ml/candidate_metric.rb
index e03a8b83ee6..8e13a46d6b4 100644
--- a/app/models/ml/candidate_metric.rb
+++ b/app/models/ml/candidate_metric.rb
@@ -6,5 +6,7 @@ module Ml
validates :name, length: { maximum: 250 }, presence: true
belongs_to :candidate, class_name: 'Ml::Candidate'
+
+ scope :latest, -> { select('DISTINCT ON (candidate_id, name) *').order('candidate_id, name, id DESC') }
end
end
diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb
index a32099e8a0c..05b238b960d 100644
--- a/app/models/ml/experiment.rb
+++ b/app/models/ml/experiment.rb
@@ -23,7 +23,7 @@ module Ml
end
def by_project_id(project_id)
- where(project_id: project_id)
+ where(project_id: project_id).order(id: :desc)
end
end
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 42f362876bb..51c39ad4ec3 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -40,9 +40,9 @@ class Namespace < ApplicationRecord
PATH_TRAILING_VIOLATIONS = %w[.git .atom .].freeze
- # The first date in https://docs.gitlab.com/ee/user/usage_quotas.html#namespace-storage-limit-enforcement-schedule
- # Determines when we start enforcing namespace storage
- MIN_STORAGE_ENFORCEMENT_DATE = Date.new(2022, 10, 19)
+ # 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
@@ -91,6 +91,7 @@ class Namespace < ApplicationRecord
validates :name,
presence: true,
length: { maximum: 255 }
+ validates :name, uniqueness: { scope: [:type, :parent_id] }, if: -> { parent_id.present? }
validates :description, length: { maximum: 255 }
@@ -550,11 +551,12 @@ class Namespace < ApplicationRecord
end
def shared_runners_setting_higher_than?(other_setting)
- if other_setting == SR_ENABLED
+ case other_setting
+ when SR_ENABLED
false
- elsif other_setting == SR_DISABLED_WITH_OVERRIDE
+ when SR_DISABLED_WITH_OVERRIDE
shared_runners_setting == SR_ENABLED
- elsif other_setting == SR_DISABLED_AND_UNOVERRIDABLE
+ when SR_DISABLED_AND_UNOVERRIDABLE
shared_runners_setting == SR_ENABLED || shared_runners_setting == SR_DISABLED_WITH_OVERRIDE
else
raise ArgumentError
diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb
index 6a87fba57ac..3e6371b0c4d 100644
--- a/app/models/namespace_setting.rb
+++ b/app/models/namespace_setting.rb
@@ -4,11 +4,6 @@ class NamespaceSetting < ApplicationRecord
include CascadingNamespaceSettingAttribute
include Sanitizable
include ChronicDurationAttribute
- include IgnorableColumns
-
- ignore_columns %i[exclude_from_free_user_cap include_for_free_user_cap_preview],
- remove_with: '15.5',
- remove_after: '2022-09-23'
cascading_attr :delayed_project_removal
diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb
index a034d97a6bb..7ffcb8b9219 100644
--- a/app/models/network/graph.rb
+++ b/app/models/network/graph.rb
@@ -23,6 +23,8 @@ module Network
protected
def collect_notes
+ return {} if Feature.enabled?(:disable_network_graph_notes_count, @project, type: :experiment)
+
h = Hash.new(0)
@project
diff --git a/app/models/note.rb b/app/models/note.rb
index e444111119b..8e1f4979602 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -60,7 +60,7 @@ class Note < ApplicationRecord
# Attribute used to determine whether keep_around_commits will be skipped for diff notes.
attr_accessor :skip_keep_around_commits
- default_value_for :system, false
+ attribute :system, default: false
attr_mentionable :note, pipeline: :note
participant :author
@@ -361,14 +361,6 @@ class Note < ApplicationRecord
super(noteable_type.to_s.classify.constantize.base_class.to_s)
end
- def noteable_assignee_or_author?(user)
- return false unless user
- return false unless noteable.respond_to?(:author_id)
- return noteable.assignee_or_author?(user) if [MergeRequest, Issue].include?(noteable.class)
-
- noteable.author_id == user.id
- end
-
def contributor?
project&.team&.contributor?(self.author_id)
end
@@ -756,7 +748,8 @@ class Note < ApplicationRecord
if user_visible_reference_count.present? && total_reference_count.present?
# if they are not equal, then there are private/confidential references as well
- user_visible_reference_count > 0 && user_visible_reference_count == total_reference_count
+ total_reference_count == 0 ||
+ user_visible_reference_count > 0 && user_visible_reference_count == total_reference_count
else
refs = all_references(user)
refs.all.any? && refs.all_visible?
diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb
index 2e45753c182..cde7b92e74a 100644
--- a/app/models/notification_setting.rb
+++ b/app/models/notification_setting.rb
@@ -3,9 +3,7 @@
class NotificationSetting < ApplicationRecord
include FromUnion
- enum level: { global: 3, watch: 2, participating: 1, mention: 4, disabled: 0, custom: 5 }
-
- default_value_for :level, NotificationSetting.levels[:global]
+ enum level: { global: 3, watch: 2, participating: 1, mention: 4, disabled: 0, custom: 5 }, _default: :global
belongs_to :user
belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
diff --git a/app/models/oauth_access_token.rb b/app/models/oauth_access_token.rb
index eac99e8d441..8e79a750793 100644
--- a/app/models/oauth_access_token.rb
+++ b/app/models/oauth_access_token.rb
@@ -31,8 +31,6 @@ class OauthAccessToken < Doorkeeper::AccessToken
# have `reuse_access_tokens` disabled and we also hash tokens.
# This ensures we don't accidentally return a hashed token value.
def self.matching_token_for(application, resource_owner, scopes)
- return if Feature.enabled?(:hash_oauth_tokens)
-
- super
+ # no-op
end
end
diff --git a/app/models/operations/feature_flag.rb b/app/models/operations/feature_flag.rb
index e36c59366fe..0df8c87f73f 100644
--- a/app/models/operations/feature_flag.rb
+++ b/app/models/operations/feature_flag.rb
@@ -16,8 +16,8 @@ module Operations
has_internal_id :iid, scope: :project
- default_value_for :active, true
- default_value_for :version, :new_version_flag
+ attribute :active, default: true
+ attribute :version, default: :new_version_flag
# strategies exists only for the second version
has_many :strategies, class_name: 'Operations::FeatureFlags::Strategy'
diff --git a/app/models/packages/go/module_version.rb b/app/models/packages/go/module_version.rb
index c442b2416f1..5869a03e081 100644
--- a/app/models/packages/go/module_version.rb
+++ b/app/models/packages/go/module_version.rb
@@ -21,9 +21,10 @@ module Packages
raise ArgumentError, "mod is required" unless mod
raise ArgumentError, "commit is required" unless commit
- if type == :ref
+ case type
+ when :ref
raise ArgumentError, "ref is required" unless ref
- elsif type == :pseudo
+ when :pseudo
raise ArgumentError, "name is required" unless name
raise ArgumentError, "semver is required" unless semver
end
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 16d5492a65e..328c67a0711 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -10,8 +10,8 @@ class PagesDomain < ApplicationRecord
SSL_RENEWAL_THRESHOLD = 30.days.freeze
enum certificate_source: { user_provided: 0, gitlab_provided: 1 }, _prefix: :certificate
- enum scope: { instance: 0, group: 1, project: 2 }, _prefix: :scope
- enum usage: { pages: 0, serverless: 1 }, _prefix: :usage
+ enum scope: { instance: 0, group: 1, project: 2 }, _prefix: :scope, _default: :project
+ enum usage: { pages: 0, serverless: 1 }, _prefix: :usage, _default: :pages
belongs_to :project
has_many :acme_orders, class_name: "PagesDomainAcmeOrder"
@@ -35,10 +35,8 @@ class PagesDomain < ApplicationRecord
validate :validate_intermediates, if: ->(domain) { domain.certificate.present? && domain.certificate_changed? }
validate :validate_custom_domain_count_per_project, on: :create
- default_value_for(:auto_ssl_enabled, allows_nil: false) { ::Gitlab::LetsEncrypt.enabled? }
- default_value_for :scope, allows_nil: false, value: :project
- default_value_for :wildcard, allows_nil: false, value: false
- default_value_for :usage, allows_nil: false, value: :pages
+ attribute :auto_ssl_enabled, default: -> { ::Gitlab::LetsEncrypt.enabled? }
+ attribute :wildcard, default: false
attr_encrypted :key,
mode: :per_attribute_iv_and_salt,
@@ -50,7 +48,7 @@ class PagesDomain < ApplicationRecord
scope :for_project, ->(project) { where(project: project) }
- scope :enabled, -> { where('enabled_until >= ?', Time.current ) }
+ scope :enabled, -> { where('enabled_until >= ?', Time.current) }
scope :needs_verification, -> do
verified_at = arel_table[:verified_at]
enabled_until = arel_table[:enabled_until]
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index f0ed1822da6..3126dba9d6d 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -11,8 +11,6 @@ class PersonalAccessToken < ApplicationRecord
add_authentication_token_field :token, digest: true
- REDIS_EXPIRY_TIME = 3.minutes
-
# PATs are 20 characters + optional configurable settings prefix (0..20)
TOKEN_LENGTH_RANGE = (20..40).freeze
@@ -34,8 +32,6 @@ class PersonalAccessToken < ApplicationRecord
scope :for_user, -> (user) { where(user: user) }
scope :for_users, -> (users) { where(user: users) }
scope :preload_users, -> { preload(:user) }
- scope :order_expires_at_asc, -> { reorder(expires_at: :asc) }
- scope :order_expires_at_desc, -> { reorder(expires_at: :desc) }
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 }) }
@@ -55,35 +51,10 @@ class PersonalAccessToken < ApplicationRecord
!revoked? && !expired?
end
- def self.redis_getdel(user_id)
- Gitlab::Redis::SharedState.with do |redis|
- redis_key = redis_shared_state_key(user_id)
- encrypted_token = redis.get(redis_key)
- redis.del(redis_key)
-
- begin
- Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token)
- rescue StandardError => e
- logger.warn "Failed to decrypt #{self.name} value stored in Redis for key ##{redis_key}: #{e.class}"
- encrypted_token
- end
- end
- end
-
- def self.redis_store!(user_id, token)
- encrypted_token = Gitlab::CryptoHelper.aes256_gcm_encrypt(token)
-
- Gitlab::Redis::SharedState.with do |redis|
- redis.set(redis_shared_state_key(user_id), encrypted_token, ex: REDIS_EXPIRY_TIME)
- end
- end
-
override :simple_sorts
def self.simple_sorts
super.merge(
{
- 'expires_at_asc' => -> { order_expires_at_asc },
- 'expires_at_desc' => -> { order_expires_at_desc },
'expires_at_asc_id_desc' => -> { order_expires_at_asc_id_desc }
}
)
@@ -121,10 +92,6 @@ class PersonalAccessToken < ApplicationRecord
self.scopes = Gitlab::Auth::DEFAULT_SCOPES if self.scopes.empty?
end
-
- def self.redis_shared_state_key(user_id)
- "gitlab:personal_access_token:#{user_id}"
- end
end
PersonalAccessToken.prepend_mod_with('PersonalAccessToken')
diff --git a/app/models/postgresql/detached_partition.rb b/app/models/postgresql/detached_partition.rb
index 12b48895e0c..b0dd52c9657 100644
--- a/app/models/postgresql/detached_partition.rb
+++ b/app/models/postgresql/detached_partition.rb
@@ -3,5 +3,9 @@
module Postgresql
class DetachedPartition < ::Gitlab::Database::SharedModel
scope :ready_to_drop, -> { where('drop_after < ?', Time.current) }
+
+ def fully_qualified_table_name
+ "#{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.#{table_name}"
+ end
end
end
diff --git a/app/models/preloaders/project_root_ancestor_preloader.rb b/app/models/preloaders/project_root_ancestor_preloader.rb
index 1e935249407..6192f79ce2c 100644
--- a/app/models/preloaders/project_root_ancestor_preloader.rb
+++ b/app/models/preloaders/project_root_ancestor_preloader.rb
@@ -9,7 +9,7 @@ module Preloaders
end
def execute
- return if @projects.is_a?(ActiveRecord::NullRelation)
+ return unless @projects.is_a?(ActiveRecord::Relation)
return unless ::Feature.enabled?(:use_traversal_ids)
root_query = Namespace.joins("INNER JOIN (#{join_sql}) as root_query ON root_query.root_id = namespaces.id")
diff --git a/app/models/preloaders/user_max_access_level_in_projects_preloader.rb b/app/models/preloaders/user_max_access_level_in_projects_preloader.rb
index 2e2272a2ef5..c9fd5e7718a 100644
--- a/app/models/preloaders/user_max_access_level_in_projects_preloader.rb
+++ b/app/models/preloaders/user_max_access_level_in_projects_preloader.rb
@@ -7,9 +7,11 @@ module Preloaders
def initialize(projects, user)
@projects = if projects.is_a?(Array)
Project.where(id: projects)
- else
+ elsif Feature.enabled?(:projects_preloader_fix)
# Push projects base query in to a sub-select to avoid
# table name clashes. Performs better than aliasing.
+ Project.where(id: projects.subquery(:id))
+ else
Project.where(id: projects.reselect(:id))
end
@@ -17,6 +19,8 @@ module Preloaders
end
def execute
+ return unless @user
+
project_authorizations = ProjectAuthorization.arel_table
auths = @projects
diff --git a/app/models/project.rb b/app/models/project.rb
index 7b61010ab01..a07d4147228 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -39,6 +39,7 @@ class Project < ApplicationRecord
include BulkUsersByEmailLoad
include RunnerTokenExpirationInterval
include BlocksUnsafeSerialization
+ include Subquery
extend Gitlab::Cache::RequestCache
extend Gitlab::Utils::Override
@@ -222,6 +223,7 @@ class Project < ApplicationRecord
has_one :youtrack_integration, class_name: 'Integrations::Youtrack'
has_one :zentao_integration, class_name: 'Integrations::Zentao'
+ has_one :wiki_repository, class_name: 'Projects::WikiRepository', inverse_of: :project
has_one :root_of_fork_network,
foreign_key: 'root_project_id',
inverse_of: :root_project,
@@ -451,7 +453,7 @@ class Project < ApplicationRecord
:metrics_dashboard_access_level, :analytics_access_level,
:operations_access_level, :security_and_compliance_access_level,
:container_registry_access_level, :environments_access_level, :feature_flags_access_level,
- :monitor_access_level, :releases_access_level,
+ :monitor_access_level, :releases_access_level, :infrastructure_access_level,
to: :project_feature, allow_nil: true
delegate :show_default_award_emojis, :show_default_award_emojis=,
@@ -491,6 +493,7 @@ class Project < ApplicationRecord
to: :project_setting
delegate :merge_commit_template, :merge_commit_template=, to: :project_setting, allow_nil: true
delegate :squash_commit_template, :squash_commit_template=, to: :project_setting, allow_nil: true
+ delegate :issue_branch_template, :issue_branch_template=, to: :project_setting, allow_nil: true
delegate :log_jira_dvcs_integration_usage, :jira_dvcs_server_last_sync_at, :jira_dvcs_cloud_last_sync_at, to: :feature_usage
@@ -1616,7 +1619,7 @@ class Project < ApplicationRecord
end
def all_clusters
- group_clusters = Clusters::Cluster.joins(:groups).where(cluster_groups: { group_id: ancestors_upto } )
+ group_clusters = Clusters::Cluster.joins(:groups).where(cluster_groups: { group_id: ancestors_upto })
instance_clusters = Clusters::Cluster.instance_type
Clusters::Cluster.from_union([clusters, group_clusters, instance_clusters])
@@ -1705,7 +1708,11 @@ class Project < ApplicationRecord
end
def has_active_integrations?(hooks_scope = :push_hooks)
- integrations.public_send(hooks_scope).any? # rubocop:disable GitlabSecurity/PublicSend
+ @has_active_integrations ||= {} # rubocop: disable Gitlab/PredicateMemoization
+
+ return @has_active_integrations[hooks_scope] if @has_active_integrations.key?(hooks_scope)
+
+ @has_active_integrations[hooks_scope] = integrations.public_send(hooks_scope).any? # rubocop:disable GitlabSecurity/PublicSend
end
def feature_usage
@@ -2729,11 +2736,6 @@ class Project < ApplicationRecord
ci_config_path.blank? || ci_config_path == Gitlab::FileDetector::PATTERNS[:gitlab_ci]
end
- # DO NOT USE. This method will be deprecated soon
- def uses_external_project_ci_config?
- !!(ci_config_path =~ %r{@.+/.+})
- end
-
def limited_protected_branches(limit)
protected_branches.limit(limit)
end
@@ -2784,7 +2786,7 @@ class Project < ApplicationRecord
return unless service_desk_enabled?
config = Gitlab.config.incoming_email
- wildcard = Gitlab::IncomingEmail::WILDCARD_PLACEHOLDER
+ wildcard = Gitlab::Email::Common::WILDCARD_PLACEHOLDER
config.address&.gsub(wildcard, "#{full_path_slug}-#{default_service_desk_suffix}")
end
@@ -2854,11 +2856,6 @@ class Project < ApplicationRecord
repository.gitlab_ci_yml_for(sha, ci_config_path_or_default)
end
- # DO NOT USE. This method will be deprecated soon
- def ci_config_external_project
- Project.find_by_full_path(ci_config_path.split('@', 2).last)
- end
-
def enabled_group_deploy_keys
return GroupDeployKey.none unless group
@@ -2927,10 +2924,6 @@ class Project < ApplicationRecord
ci_cd_settings.keep_latest_artifact?
end
- def runner_token_expiration_interval
- ci_cd_settings&.runner_token_expiration_interval
- end
-
def group_runners_enabled?
return false unless ci_cd_settings
@@ -3006,7 +2999,7 @@ class Project < ApplicationRecord
end
def work_items_create_from_markdown_feature_flag_enabled?
- work_items_feature_flag_enabled? && (group&.work_items_create_from_markdown_feature_flag_enabled? || Feature.enabled?(:work_items_create_from_markdown))
+ group&.work_items_create_from_markdown_feature_flag_enabled? || Feature.enabled?(:work_items_create_from_markdown)
end
def enqueue_record_project_target_platforms
diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb
index 8b43e5e5d63..3623b3be20d 100644
--- a/app/models/project_authorization.rb
+++ b/app/models/project_authorization.rb
@@ -31,6 +31,7 @@ class ProjectAuthorization < ApplicationRecord
def self.insert_all_in_batches(attributes, per_batch = BATCH_SIZE)
add_delay = add_delay_between_batches?(entire_size: attributes.size, batch_size: per_batch)
+ log_details(entire_size: attributes.size) if add_delay
attributes.each_slice(per_batch) do |attributes_batch|
insert_all(attributes_batch)
@@ -40,6 +41,7 @@ class ProjectAuthorization < ApplicationRecord
def self.delete_all_in_batches_for_project(project:, user_ids:, per_batch: BATCH_SIZE)
add_delay = add_delay_between_batches?(entire_size: user_ids.size, batch_size: per_batch)
+ log_details(entire_size: user_ids.size) if add_delay
user_ids.each_slice(per_batch) do |user_ids_batch|
project.project_authorizations.where(user_id: user_ids_batch).delete_all
@@ -49,6 +51,7 @@ class ProjectAuthorization < ApplicationRecord
def self.delete_all_in_batches_for_user(user:, project_ids:, per_batch: BATCH_SIZE)
add_delay = add_delay_between_batches?(entire_size: project_ids.size, batch_size: per_batch)
+ log_details(entire_size: project_ids.size) if add_delay
project_ids.each_slice(per_batch) do |project_ids_batch|
user.project_authorizations.where(project_id: project_ids_batch).delete_all
@@ -65,6 +68,13 @@ class ProjectAuthorization < ApplicationRecord
Feature.enabled?(:enable_minor_delay_during_project_authorizations_refresh)
end
+ private_class_method def self.log_details(entire_size:)
+ Gitlab::AppLogger.info(
+ entire_size: entire_size,
+ message: 'Project authorizations refresh performed with delay'
+ )
+ end
+
private_class_method def self.perform_delay
sleep(SLEEP_DELAY)
end
diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb
index d7a5d0d9d84..cc9003423be 100644
--- a/app/models/project_ci_cd_setting.rb
+++ b/app/models/project_ci_cd_setting.rb
@@ -17,8 +17,8 @@ class ProjectCiCdSetting < ApplicationRecord
},
allow_nil: true
- default_value_for :forward_deployment_enabled, true
- default_value_for :separated_caches, true
+ attribute :forward_deployment_enabled, default: true
+ attribute :separated_caches, default: true
chronic_duration_attr :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index dad8aaf0625..11f4a3f3b6f 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -25,6 +25,7 @@ class ProjectFeature < ApplicationRecord
environments
feature_flags
releases
+ infrastructure
].freeze
EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance, :pages]).freeze
diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb
index 6d40544fad4..7116ccd9824 100644
--- a/app/models/project_setting.rb
+++ b/app/models/project_setting.rb
@@ -2,6 +2,7 @@
class ProjectSetting < ApplicationRecord
include ::Gitlab::Utils::StrongMemoize
+ include EachBatch
ALLOWED_TARGET_PLATFORMS = %w(ios osx tvos watchos android).freeze
@@ -20,12 +21,13 @@ class ProjectSetting < ApplicationRecord
validates :merge_commit_template, length: { maximum: Project::MAX_COMMIT_TEMPLATE_LENGTH }
validates :squash_commit_template, length: { maximum: Project::MAX_COMMIT_TEMPLATE_LENGTH }
+ validates :issue_branch_template, length: { maximum: Issue::MAX_BRANCH_TEMPLATE }
validates :target_platforms, inclusion: { in: ALLOWED_TARGET_PLATFORMS }
validates :suggested_reviewers_enabled, inclusion: { in: [true, false] }
validate :validates_mr_default_target_self
- default_value_for(:legacy_open_source_license_available) do
+ attribute :legacy_open_source_license_available, default: -> do
Feature.enabled?(:legacy_open_source_license_available, type: :ops)
end
@@ -57,7 +59,7 @@ class ProjectSetting < ApplicationRecord
!!super
end
end
- strong_memoize_attr :show_diff_preview_in_email
+ strong_memoize_attr :show_diff_preview_in_email?, :show_diff_preview_in_email
private
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index f108e43015e..0570be85ad1 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -7,8 +7,8 @@ class ProjectStatistics < ApplicationRecord
belongs_to :project
belongs_to :namespace
- default_value_for :wiki_size, 0
- default_value_for :snippets_size, 0
+ attribute :wiki_size, default: 0
+ attribute :snippets_size, default: 0
counter_attribute :build_artifacts_size
@@ -95,8 +95,7 @@ class ProjectStatistics < ApplicationRecord
# and the column can be nil.
# This means that, when the columns were added, all rows had nil
# values on them.
- # Therefore, any call to any of those methods will return nil instead
- # of 0, because `default_value_for` works with new records, not existing ones.
+ # Therefore, any call to any of those methods will return nil instead of 0.
#
# These two methods provide consistency and avoid returning nil.
def wiki_size
diff --git a/app/models/projects/import_export/relation_export.rb b/app/models/projects/import_export/relation_export.rb
index 15198049f87..9bdf10d7c0e 100644
--- a/app/models/projects/import_export/relation_export.rb
+++ b/app/models/projects/import_export/relation_export.rb
@@ -34,11 +34,18 @@ module Projects
scope :by_relation, -> (relation) { where(relation: relation) }
+ STATUS = {
+ queued: 0,
+ started: 1,
+ finished: 2,
+ failed: 3
+ }.freeze
+
state_machine :status, initial: :queued do
- state :queued, value: 0
- state :started, value: 1
- state :finished, value: 2
- state :failed, value: 3
+ state :queued, value: STATUS[:queued]
+ state :started, value: STATUS[:started]
+ state :finished, value: STATUS[:finished]
+ state :failed, value: STATUS[:failed]
event :start do
transition queued: :started
diff --git a/app/models/projects/wiki_repository.rb b/app/models/projects/wiki_repository.rb
new file mode 100644
index 00000000000..88382adbcb7
--- /dev/null
+++ b/app/models/projects/wiki_repository.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Projects
+ class WikiRepository < ApplicationRecord
+ self.table_name = :project_wiki_repositories
+
+ belongs_to :project, inverse_of: :wiki_repository
+
+ validates :project, presence: true, uniqueness: true
+ end
+end
+
+Projects::WikiRepository.prepend_mod_with('Projects::WikiRepository')
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index dfd5c315f6e..80967c1b072 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -4,6 +4,10 @@ class ProtectedBranch < ApplicationRecord
include ProtectedRef
include Gitlab::SQL::Pattern
+ belongs_to :group, foreign_key: :namespace_id, touch: true, inverse_of: :protected_branches
+
+ validate :validate_either_project_or_top_group
+
scope :requiring_code_owner_approval,
-> { where(code_owner_approval_required: true) }
@@ -99,6 +103,18 @@ class ProtectedBranch < ApplicationRecord
def default_branch?
name == project.default_branch
end
+
+ private
+
+ def validate_either_project_or_top_group
+ if !project && !group
+ errors.add(:base, _('must be associated with a Group or a Project'))
+ elsif project && group
+ errors.add(:base, _('cannot be associated with both a Group and a Project'))
+ elsif group && group.root_ancestor != group
+ errors.add(:base, _('cannot be associated with a subgroup'))
+ end
+ end
end
ProtectedBranch.prepend_mod_with('ProtectedBranch')
diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb
index 5b2467daddc..e89cb3aabc7 100644
--- a/app/models/protected_tag.rb
+++ b/app/models/protected_tag.rb
@@ -4,6 +4,7 @@ class ProtectedTag < ApplicationRecord
include ProtectedRef
validates :name, uniqueness: { scope: :project_id }
+ validates :project, presence: true
protected_ref_access_levels :create
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 3413b3e3424..95d1b815e74 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -98,6 +98,10 @@ class Repository
alias_method :raw, :raw_repository
+ def flipper_id
+ raw_repository.flipper_id
+ end
+
# Don't use this! It's going away. Use Gitaly to read or write from repos.
def path_to_repo
@path_to_repo ||=
@@ -1232,7 +1236,8 @@ class Repository
Gitlab::Git::Repository.new(shard,
disk_path + '.git',
repo_type.identifier_for_container(container),
- container.full_path)
+ container.full_path,
+ container: container)
end
end
diff --git a/app/models/serverless/domain_cluster.rb b/app/models/serverless/domain_cluster.rb
index 1effabf1c22..561bfc65b2b 100644
--- a/app/models/serverless/domain_cluster.rb
+++ b/app/models/serverless/domain_cluster.rb
@@ -19,7 +19,7 @@ module Serverless
validates :uuid, presence: true, uniqueness: true, length: { is: ::Serverless::Domain::UUID_LENGTH },
format: { with: HEX_REGEXP, message: 'only allows hex characters' }
- default_value_for(:uuid, allows_nil: false) { ::Serverless::Domain.generate_uuid }
+ after_initialize :set_uuid, if: :new_record?
delegate :domain, to: :pages_domain
delegate :cluster, to: :knative
@@ -29,5 +29,11 @@ module Serverless
.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/terraform/state.rb b/app/models/terraform/state.rb
index e5c8f4ab32a..8a207c891e2 100644
--- a/app/models/terraform/state.rb
+++ b/app/models/terraform/state.rb
@@ -28,7 +28,7 @@ module Terraform
validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH },
format: { with: HEX_REGEXP, message: 'only allows hex characters' }
- default_value_for(:uuid, allows_nil: false) { SecureRandom.hex(UUID_LENGTH / 2) }
+ attribute :uuid, default: -> { SecureRandom.hex(UUID_LENGTH / 2) }
def latest_file
latest_version&.file
diff --git a/app/models/terraform/state_version.rb b/app/models/terraform/state_version.rb
index c50eaa66860..d6a16ad5b99 100644
--- a/app/models/terraform/state_version.rb
+++ b/app/models/terraform/state_version.rb
@@ -13,7 +13,7 @@ module Terraform
scope :with_files_stored_locally, -> { where(file_store: Terraform::StateUploader::Store::LOCAL) }
scope :preload_state, -> { includes(:terraform_state) }
- default_value_for(:file_store) { StateUploader.default_store }
+ attribute :file_store, default: -> { StateUploader.default_store }
mount_file_store_uploader StateUploader
diff --git a/app/models/time_tracking/timelog_category.rb b/app/models/time_tracking/timelog_category.rb
index 26614f6fc44..246e78f31cb 100644
--- a/app/models/time_tracking/timelog_category.rb
+++ b/app/models/time_tracking/timelog_category.rb
@@ -24,8 +24,7 @@ module TimeTracking
DEFAULT_COLOR = ::Gitlab::Color.of('#6699cc')
- attribute :color, ::Gitlab::Database::Type::Color.new
- default_value_for :color, DEFAULT_COLOR
+ attribute :color, ::Gitlab::Database::Type::Color.new, default: DEFAULT_COLOR
def self.find_by_name(namespace_id, name)
where(namespace: namespace_id)
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 634fa9e7eda..f2fa0df852a 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -94,7 +94,7 @@ class Todo < ApplicationRecord
#
# Returns an `ActiveRecord::Relation`.
def for_group_ids_and_descendants(group_ids)
- groups = Group.groups_including_descendants_by(group_ids)
+ groups = Group.where(id: group_ids).self_and_descendants
from_union(
[
diff --git a/app/models/user.rb b/app/models/user.rb
index 6d198fc755b..24f947183a2 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -83,7 +83,10 @@ class User < ApplicationRecord
serialize :otp_backup_codes, JSON # rubocop:disable Cop/ActiveRecordSerialize
devise :lockable, :recoverable, :rememberable, :trackable,
- :validatable, :omniauthable, :confirmable, :registerable, :pbkdf2_encryptable
+ :validatable, :omniauthable, :confirmable, :registerable
+
+ # Must be included after `devise`
+ include EncryptedUserPassword
include AdminChangedPasswordNotifier
@@ -185,7 +188,7 @@ class User < ApplicationRecord
has_many :personal_projects, through: :namespace, source: :projects
has_many :project_members, -> { where(requested_at: nil) }
has_many :projects, through: :project_members
- has_many :created_projects, foreign_key: :creator_id, class_name: 'Project'
+ has_many :created_projects, foreign_key: :creator_id, class_name: 'Project', dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
has_many :projects_with_active_memberships, -> { where(members: { state: ::Member::STATE_ACTIVE }) }, through: :project_members, source: :project
has_many :users_star_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :starred_projects, through: :users_star_projects, source: :project
@@ -258,6 +261,8 @@ class User < ApplicationRecord
has_many :resource_state_events, 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
+
#
# Validations
#
@@ -420,10 +425,6 @@ class User < ApplicationRecord
end
# rubocop: disable CodeReuse/ServiceClass
- # Ideally we should not call a service object here but user.block
- # is also called by Users::MigrateToGhostUserService which references
- # this state transition object in order to do a rollback.
- # For this reason the tradeoff is to disable this cop.
after_transition any => :blocked do |user|
user.run_after_commit do
Ci::DropPipelineService.new.execute_async_for_all(user.pipelines, :user_blocked, user)
@@ -447,6 +448,14 @@ class User < ApplicationRecord
after_transition banned: :active do |user|
user.banned_user&.destroy
end
+
+ after_transition any => :active do |user|
+ user.starred_projects.update_counters(star_count: 1)
+ end
+
+ after_transition active: any do |user|
+ user.starred_projects.update_counters(star_count: -1)
+ end
end
# Scopes
@@ -693,6 +702,8 @@ class User < ApplicationRecord
#
# Returns an ActiveRecord::Relation.
def search(query, **options)
+ return none unless query.is_a?(String)
+
query = query&.delete_prefix('@')
return none if query.blank?
@@ -937,26 +948,14 @@ class User < ApplicationRecord
reset_password_sent_at.present? && reset_password_sent_at >= 1.minute.ago
end
- def authenticatable_salt
- return encrypted_password[0, 29] unless Feature.enabled?(:pbkdf2_password_encryption)
- return super if password_strategy == :pbkdf2_sha512
-
- encrypted_password[0, 29]
- end
-
# Overwrites valid_password? from Devise::Models::DatabaseAuthenticatable
# In constant-time, check both that the password isn't on a denylist AND
# that the password is the user's password
def valid_password?(password)
return false unless password_allowed?(password)
return false if password_automatically_set?
- return super if Feature.enabled?(:pbkdf2_password_encryption)
- Devise::Encryptor.compare(self.class, encrypted_password, password)
- rescue Devise::Pbkdf2Encryptable::Encryptors::InvalidHash
- validate_and_migrate_bcrypt_password(password)
- rescue ::BCrypt::Errors::InvalidHash
- false
+ super
end
def generate_otp_backup_codes!
@@ -975,27 +974,6 @@ class User < ApplicationRecord
end
end
- # This method should be removed once the :pbkdf2_password_encryption feature flag is removed.
- def password=(new_password)
- if Feature.enabled?(:pbkdf2_password_encryption) && Feature.enabled?(:pbkdf2_password_encryption_write, self)
- super
- else
- # Copied from Devise DatabaseAuthenticatable.
- @password = new_password
- self.encrypted_password = Devise::Encryptor.digest(self.class, new_password) if new_password.present?
- end
- end
-
- def password_strategy
- super
- rescue Devise::Pbkdf2Encryptable::Encryptors::InvalidHash
- begin
- return :bcrypt if BCrypt::Password.new(encrypted_password)
- rescue BCrypt::Errors::InvalidHash
- :unknown
- end
- end
-
# See https://gitlab.com/gitlab-org/security/gitlab/-/issues/638
DISALLOWED_PASSWORDS = %w[123qweQWE!@#000000000].freeze
@@ -1224,6 +1202,10 @@ class User < ApplicationRecord
authorized_projects(Gitlab::Access::REPORTER).non_archived.with_issues_enabled
end
+ def preloaded_member_roles_for_projects(projects)
+ # overridden in EE
+ end
+
# rubocop: disable CodeReuse/ServiceClass
def require_ssh_key?
count = Users::KeysCountService.new(self).count
@@ -1786,7 +1768,7 @@ class User < ApplicationRecord
end
def owns_runner?(runner)
- ci_owned_runners.exists?(runner.id)
+ ci_owned_runners.include?(runner)
end
def notification_email_for(notification_group)
@@ -2440,15 +2422,6 @@ class User < ApplicationRecord
Ci::NamespaceMirror.contains_traversal_ids(traversal_ids)
end
-
- def validate_and_migrate_bcrypt_password(password)
- return false unless Devise::Encryptor.compare(self.class, encrypted_password, password)
- return true unless Feature.enabled?(:pbkdf2_password_encryption_write, self)
-
- update_attribute(:password, password)
- rescue ::BCrypt::Errors::InvalidHash
- false
- end
end
User.prepend_mod_with('User')
diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb
index ae6950d800c..b037d07658d 100644
--- a/app/models/users/callout.rb
+++ b/app/models/users/callout.rb
@@ -62,7 +62,8 @@ module Users
namespace_storage_limit_banner_error_threshold: 58, # EE-only
project_quality_summary_feedback: 59, # EE-only
merge_request_settings_moved_callout: 60,
- new_top_level_group_alert: 61
+ new_top_level_group_alert: 61,
+ artifacts_management_page_feedback_banner: 62
}
validates :feature_name,
diff --git a/app/models/users/ghost_user_migration.rb b/app/models/users/ghost_user_migration.rb
index 1d93498e88b..4578e0503c3 100644
--- a/app/models/users/ghost_user_migration.rb
+++ b/app/models/users/ghost_user_migration.rb
@@ -8,5 +8,7 @@ module Users
belongs_to :initiator_user, class_name: 'User'
validates :user_id, presence: true
+
+ scope :consume_order, -> { order(:consume_after, :id) }
end
end
diff --git a/app/models/users/namespace_commit_email.rb b/app/models/users/namespace_commit_email.rb
new file mode 100644
index 00000000000..4ec02f12717
--- /dev/null
+++ b/app/models/users/namespace_commit_email.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Users
+ class NamespaceCommitEmail < ApplicationRecord
+ belongs_to :user
+ belongs_to :namespace
+ belongs_to :email
+
+ validates :user, presence: true
+ validates :namespace, presence: true
+ validates :email, presence: true
+ validates :user_id, uniqueness: { scope: [:namespace_id] }
+ end
+end
diff --git a/app/models/users_star_project.rb b/app/models/users_star_project.rb
index 9a514b82506..6cffc97822d 100644
--- a/app/models/users_star_project.rb
+++ b/app/models/users_star_project.rb
@@ -3,7 +3,7 @@
class UsersStarProject < ApplicationRecord
include Sortable
- belongs_to :project, counter_cache: :star_count
+ belongs_to :project
belongs_to :user
validates :user, presence: true
@@ -12,6 +12,10 @@ class UsersStarProject < ApplicationRecord
alias_attribute :starred_since, :created_at
+ after_create :increment_project_star_count
+ after_destroy :decrement_project_star_count
+
+ scope :with_active_user, -> { joins(:user).merge(User.with_state(:active)) }
scope :order_user_name_asc, -> { joins(:user).merge(User.order_name_asc) }
scope :order_user_name_desc, -> { joins(:user).merge(User.order_name_desc) }
scope :by_project, -> (project) { where(project_id: project.id) }
@@ -35,4 +39,14 @@ class UsersStarProject < ApplicationRecord
joins(:user).merge(User.search(query, use_minimum_char_limit: false))
end
end
+
+ private
+
+ def increment_project_star_count
+ Project.update_counters(project, star_count: 1) if user.active?
+ end
+
+ def decrement_project_star_count
+ Project.update_counters(project, star_count: -1) if user.active?
+ end
end
diff --git a/app/models/wiki.rb b/app/models/wiki.rb
index b718c3a096f..57488749b76 100644
--- a/app/models/wiki.rb
+++ b/app/models/wiki.rb
@@ -190,7 +190,7 @@ class Wiki
end
def empty?
- !repository_exists? || list_page_paths.empty?
+ !repository_exists? || list_page_paths(limit: 1).empty?
end
def exists?
@@ -207,9 +207,29 @@ class Wiki
#
# Returns an Array of GitLab WikiPage instances or an
# empty Array if this Wiki has no pages.
- def list_pages(limit: 0, direction: DIRECTION_ASC, load_content: false)
+ def list_pages(direction: DIRECTION_ASC, load_content: false, limit: 0, offset: 0)
create_wiki_repository unless repository_exists?
- list_pages_with_repository_rpcs(limit: limit, direction: direction, load_content: load_content)
+
+ paths = list_page_paths(limit: limit, offset: offset)
+ return [] if paths.empty?
+
+ pages = paths.map do |path|
+ page = Gitlab::Git::WikiPage.new(
+ url_path: sluggified_title(strip_extension(path)),
+ title: canonicalize_filename(path),
+ format: find_page_format(path),
+ path: sluggified_title(path),
+ raw_data: '',
+ name: canonicalize_filename(path),
+ historical: false
+ )
+ WikiPage.new(self, page)
+ end
+ sort_pages!(pages, direction)
+ pages = pages.take(limit) if limit > 0
+ fetch_pages_content!(pages) if load_content
+
+ pages
end
def sidebar_entries(limit: Gitlab::WikiPages::MAX_SIDEBAR_PAGES, **options)
@@ -229,7 +249,27 @@ class Wiki
# Returns an initialized WikiPage instance or nil
def find_page(title, version = nil, load_content: true)
create_wiki_repository unless repository_exists?
- find_page_with_repository_rpcs(title, version, load_content: load_content)
+
+ version = version.presence || default_branch
+ path = find_matched_file(title, version)
+ return if path.blank?
+
+ blob_options = load_content ? {} : { limit: 0 }
+ blob = repository.blob_at(version, path, **blob_options)
+ commit = repository.commit(blob.commit_id)
+ format = find_page_format(path)
+
+ page = Gitlab::Git::WikiPage.new(
+ url_path: sluggified_title(strip_extension(path)),
+ title: canonicalize_filename(path),
+ format: format,
+ path: sluggified_title(path),
+ raw_data: blob.data,
+ name: canonicalize_filename(path),
+ historical: version == default_branch ? false : check_page_historical(path, commit),
+ version: Gitlab::Git::WikiPageVersion.new(commit, format)
+ )
+ WikiPage.new(self, page)
end
def find_sidebar(version = nil)
@@ -315,12 +355,6 @@ class Wiki
[title, title_array.join("/")]
end
- # TODO: This method is redundant. Should be replaced by create_wiki_repository
- def ensure_repository
- create_wiki_repository
- raise CouldNotCreateWikiError unless repository_exists?
- end
-
def hook_attrs
{
web_url: web_url,
@@ -457,7 +491,7 @@ 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)
+ matched_files = repository.search_files_by_regexp(path_regexp, version, limit: 1)
return if matched_files.blank?
Gitlab::EncodingHelper.encode_utf8_no_detect(matched_files.first)
@@ -472,29 +506,6 @@ class Wiki
repository.last_commit_for_path(default_branch, path)&.id != commit&.id
end
- def find_page_with_repository_rpcs(title, version, load_content: true)
- version = version.presence || default_branch
- path = find_matched_file(title, version)
- return if path.blank?
-
- blob_options = load_content ? {} : { limit: 0 }
- blob = repository.blob_at(version, path, **blob_options)
- commit = repository.commit(blob.commit_id)
- format = find_page_format(path)
-
- page = Gitlab::Git::WikiPage.new(
- url_path: sluggified_title(strip_extension(path)),
- title: canonicalize_filename(path),
- format: format,
- path: sluggified_title(path),
- raw_data: blob.data,
- name: canonicalize_filename(path),
- historical: version == default_branch ? false : check_page_historical(path, commit),
- version: Gitlab::Git::WikiPageVersion.new(commit, format)
- )
- WikiPage.new(self, page)
- end
-
def file_extension_regexp
# We could not use ALLOWED_EXTENSIONS_REGEX constant or similar regexp with
# Regexp.union. The result combination complicated modifiers:
@@ -509,34 +520,11 @@ class Wiki
path.sub(/\.[^.]+\z/, "")
end
- def list_page_paths
+ def list_page_paths(limit: 0, offset: 0)
return [] if repository.empty?
path_regexp = Gitlab::EncodingHelper.encode_utf8_no_detect("(?i)\\.(#{file_extension_regexp})$")
- repository.search_files_by_regexp(path_regexp, default_branch)
- end
-
- def list_pages_with_repository_rpcs(limit:, direction:, load_content:)
- paths = list_page_paths
- return [] if paths.empty?
-
- pages = paths.map do |path|
- page = Gitlab::Git::WikiPage.new(
- url_path: sluggified_title(strip_extension(path)),
- title: canonicalize_filename(path),
- format: find_page_format(path),
- path: sluggified_title(path),
- raw_data: '',
- name: canonicalize_filename(path),
- historical: false
- )
- WikiPage.new(self, page)
- end
- sort_pages!(pages, direction)
- pages = pages.take(limit) if limit > 0
- fetch_pages_content!(pages) if load_content
-
- pages
+ repository.search_files_by_regexp(path_regexp, default_branch, limit: limit, offset: offset)
end
# After migrating to normal repository RPCs, it's very expensive to sort the
@@ -552,7 +540,7 @@ class Wiki
def fetch_pages_content!(pages)
blobs =
repository
- .blobs_at(pages.map { |page| [default_branch, page.path] } )
+ .blobs_at(pages.map { |page| [default_branch, page.path] })
.to_h { |blob| [blob.path, blob.data] }
pages.each do |page|
diff --git a/app/models/work_item.rb b/app/models/work_item.rb
index 05e45fa5b29..ed6f9d161a6 100644
--- a/app/models/work_item.rb
+++ b/app/models/work_item.rb
@@ -16,8 +16,14 @@ class WorkItem < Issue
scope :inc_relations_for_permission_check, -> { includes(:author, project: :project_feature) }
- def self.assignee_association_name
- 'issue'
+ class << self
+ def assignee_association_name
+ 'issue'
+ end
+
+ def test_reports_join_column
+ 'issues.id'
+ end
end
def noteable_target_type_name
diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb
index 753fcbcb8f9..dc30899d24f 100644
--- a/app/models/work_items/type.rb
+++ b/app/models/work_items/type.rb
@@ -12,20 +12,27 @@ module WorkItems
# Base types need to exist on the DB on app startup
# This constant is used by the DB seeder
+ # TODO - where to add new icon names created?
BASE_TYPES = {
issue: { name: 'Issue', icon_name: 'issue-type-issue', enum_value: 0 },
incident: { name: 'Incident', icon_name: 'issue-type-incident', enum_value: 1 },
test_case: { name: 'Test Case', icon_name: 'issue-type-test-case', enum_value: 2 }, ## EE-only
requirement: { name: 'Requirement', icon_name: 'issue-type-requirements', enum_value: 3 }, ## EE-only
- task: { name: 'Task', icon_name: 'issue-type-task', enum_value: 4 }
+ task: { name: 'Task', icon_name: 'issue-type-task', enum_value: 4 },
+ objective: { name: 'Objective', icon_name: 'issue-type-objective', enum_value: 5 }, ## EE-only
+ key_result: { name: 'Key Result', icon_name: 'issue-type-keyresult', enum_value: 6 } ## EE-only
}.freeze
WIDGETS_FOR_TYPE = {
- issue: [Widgets::Assignees, Widgets::Labels, Widgets::Description, Widgets::Hierarchy, Widgets::StartAndDueDate],
+ issue: [Widgets::Assignees, Widgets::Labels, Widgets::Description, Widgets::Hierarchy, Widgets::StartAndDueDate,
+ Widgets::Milestone],
incident: [Widgets::Description, Widgets::Hierarchy],
test_case: [Widgets::Description],
requirement: [Widgets::Description],
- task: [Widgets::Assignees, Widgets::Labels, Widgets::Description, Widgets::Hierarchy, Widgets::StartAndDueDate]
+ task: [Widgets::Assignees, Widgets::Labels, Widgets::Description, Widgets::Hierarchy, Widgets::StartAndDueDate,
+ Widgets::Milestone],
+ objective: [Widgets::Assignees, Widgets::Labels, Widgets::Description, Widgets::Hierarchy, Widgets::Milestone],
+ key_result: [Widgets::Assignees, Widgets::Labels, Widgets::Description, Widgets::StartAndDueDate]
}.freeze
WI_TYPES_WITH_CREATED_HEADER = %w[issue incident].freeze
@@ -67,7 +74,7 @@ module WorkItems
end
def self.allowed_types_for_issues
- base_types.keys.excluding('task')
+ base_types.keys.excluding('task', 'objective', 'key_result')
end
def default?
diff --git a/app/models/work_items/widgets/hierarchy.rb b/app/models/work_items/widgets/hierarchy.rb
index 930aced8ace..d0819076efd 100644
--- a/app/models/work_items/widgets/hierarchy.rb
+++ b/app/models/work_items/widgets/hierarchy.rb
@@ -4,14 +4,10 @@ module WorkItems
module Widgets
class Hierarchy < Base
def parent
- return unless work_item.project.work_items_feature_flag_enabled?
-
work_item.work_item_parent
end
def children
- return WorkItem.none unless work_item.project.work_items_feature_flag_enabled?
-
work_item.work_item_children
end
end
diff --git a/app/models/work_items/widgets/milestone.rb b/app/models/work_items/widgets/milestone.rb
new file mode 100644
index 00000000000..a3e610110f1
--- /dev/null
+++ b/app/models/work_items/widgets/milestone.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ class Milestone < Base
+ delegate :milestone, to: :work_item
+ end
+ end
+end
diff --git a/app/policies/blob_policy.rb b/app/policies/blob_policy.rb
index 639b9dfeea7..8220b035603 100644
--- a/app/policies/blob_policy.rb
+++ b/app/policies/blob_policy.rb
@@ -3,5 +3,5 @@
class BlobPolicy < BasePolicy
delegate { @subject.project }
- rule { can?(:download_code) }.enable :read_blob
+ rule { can?(:read_code) }.enable :read_blob
end
diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb
index b657b569e3e..5ef926ef2e3 100644
--- a/app/policies/ci/build_policy.rb
+++ b/app/policies/ci/build_policy.rb
@@ -27,8 +27,8 @@ module Ci
false
end
- condition(:prevent_rollback) do
- @subject.prevent_rollback_deployment?
+ condition(:outdated_deployment) do
+ @subject.outdated_deployment?
end
condition(:owner_of_job) do
@@ -77,12 +77,14 @@ module Ci
# 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 | prevent_rollback) }.policy do
+ rule { ~can?(:jailbreak) & (archived | protected_ref | protected_environment) }.policy do
prevent :update_build
prevent :update_commit_status
prevent :erase_build
end
+ rule { outdated_deployment }.prevent :update_build
+
rule { can?(:admin_build) | (can?(:update_build) & owner_of_job & unprotected_ref) }.enable :erase_build
rule { can?(:public_access) & branch_allows_collaboration }.policy do
diff --git a/app/policies/commit_policy.rb b/app/policies/commit_policy.rb
index 4b358c45ec2..66ec2c5ce56 100644
--- a/app/policies/commit_policy.rb
+++ b/app/policies/commit_policy.rb
@@ -3,6 +3,6 @@
class CommitPolicy < BasePolicy
delegate { @subject.project }
- rule { can?(:download_code) }.enable :read_commit
+ rule { can?(:read_code) }.enable :read_commit
rule { ~can?(:read_commit) }.prevent :create_note
end
diff --git a/app/policies/commit_signatures/gpg_signature_policy.rb b/app/policies/commit_signatures/gpg_signature_policy.rb
new file mode 100644
index 00000000000..518a289c1f3
--- /dev/null
+++ b/app/policies/commit_signatures/gpg_signature_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module CommitSignatures
+ class GpgSignaturePolicy < BasePolicy
+ delegate { @subject.project }
+ end
+end
diff --git a/app/policies/commit_signatures/x509_commit_signature_policy.rb b/app/policies/commit_signatures/x509_commit_signature_policy.rb
new file mode 100644
index 00000000000..6b2477797fc
--- /dev/null
+++ b/app/policies/commit_signatures/x509_commit_signature_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module CommitSignatures
+ class X509CommitSignaturePolicy < BasePolicy
+ delegate { @subject.project }
+ end
+end
diff --git a/app/policies/concerns/member_policy_helpers.rb b/app/policies/concerns/member_policy_helpers.rb
new file mode 100644
index 00000000000..6c4a3caf8bf
--- /dev/null
+++ b/app/policies/concerns/member_policy_helpers.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module MemberPolicyHelpers
+ extend ActiveSupport::Concern
+
+ private
+
+ def record_is_access_request_of_self?
+ record_is_access_request? && record_belongs_to_self?
+ end
+
+ def record_is_access_request?
+ @subject.request? # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
+ def record_belongs_to_self?
+ @user && @subject.user == @user # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 406144b7a5c..fa7b117f3cd 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -120,8 +120,6 @@ class GlobalPolicy < BasePolicy
# We can't use `read_statistics` because the user may have different permissions for different projects
rule { admin }.enable :use_project_statistics_filters
- rule { admin }.enable :delete_runners
-
rule { external_user }.prevent :create_snippet
end
diff --git a/app/policies/group_member_policy.rb b/app/policies/group_member_policy.rb
index a394b63fc8e..f61f758a8e8 100644
--- a/app/policies/group_member_policy.rb
+++ b/app/policies/group_member_policy.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class GroupMemberPolicy < BasePolicy
+ include MemberPolicyHelpers
+
delegate :group
with_scope :subject
@@ -9,7 +11,11 @@ class GroupMemberPolicy < BasePolicy
desc "Membership is users' own"
with_score 0
- condition(:is_target_user) { @user && @subject.user_id == @user.id }
+ condition(:target_is_self) { record_belongs_to_self? }
+
+ desc "Membership is users' own access request"
+ with_score 0
+ condition(:access_request_of_self) { record_is_access_request_of_self? }
rule { anonymous }.policy do
prevent :update_group_member
@@ -28,9 +34,13 @@ class GroupMemberPolicy < BasePolicy
rule { project_bot & can?(:admin_group_member) }.enable :destroy_project_bot_member
- rule { is_target_user }.policy do
+ rule { target_is_self }.policy do
enable :destroy_group_member
end
+
+ rule { access_request_of_self }.policy do
+ enable :withdraw_member_access_request
+ end
end
GroupMemberPolicy.prepend_mod_with('GroupMemberPolicy')
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 7a0fb10928a..806c57bab74 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -22,7 +22,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
condition(:share_with_group_locked, scope: :subject) { @subject.share_with_group_lock? }
condition(:parent_share_with_group_locked, scope: :subject) { @subject.parent&.share_with_group_lock? }
condition(:can_change_parent_share_with_group_lock) { can?(:change_share_with_group_lock, @subject.parent) }
- condition(:migration_bot, scope: :user) { @user.migration_bot? }
+ condition(:migration_bot, scope: :user) { @user&.migration_bot? }
condition(:can_read_group_member) { can_read_group_member? }
desc "User is a project bot"
@@ -283,6 +283,11 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
prevent :create_resource_access_tokens
end
+ rule { can?(:admin_group_member) }.policy do
+ # ability to read, approve or reject member access requests of other users
+ enable :admin_member_access_request
+ end
+
rule { support_bot & has_project_with_service_desk_enabled }.policy do
enable :read_label
end
diff --git a/app/policies/incident_management/timeline_event_tag_policy.rb b/app/policies/incident_management/timeline_event_tag_policy.rb
new file mode 100644
index 00000000000..e2268d917b4
--- /dev/null
+++ b/app/policies/incident_management/timeline_event_tag_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ class TimelineEventTagPolicy < ::BasePolicy
+ delegate { @subject.project }
+ end
+end
diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb
index df065b24e64..aa07bb7dc5f 100644
--- a/app/policies/issuable_policy.rb
+++ b/app/policies/issuable_policy.rb
@@ -56,7 +56,7 @@ class IssuablePolicy < BasePolicy
end
# This rule replicates permissions in NotePolicy#can_read_confidential
- rule { can?(:reporter_access) | assignee_or_author | admin }.policy do
+ rule { can?(:reporter_access) | admin }.policy do
enable :read_internal_note
end
end
diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb
index dbfc63a0069..67b57595beb 100644
--- a/app/policies/note_policy.rb
+++ b/app/policies/note_policy.rb
@@ -23,7 +23,7 @@ class NotePolicy < BasePolicy
# Should be matched with IssuablePolicy#read_internal_note
# and EpicPolicy#read_internal_note
condition(:can_read_confidential) do
- access_level >= Gitlab::Access::REPORTER || @subject.noteable_assignee_or_author?(@user) || admin?
+ access_level >= Gitlab::Access::REPORTER || admin?
end
rule { ~editable }.prevent :admin_note
diff --git a/app/policies/packages/policies/project_policy.rb b/app/policies/packages/policies/project_policy.rb
index c754d24349a..0fb5953f2aa 100644
--- a/app/policies/packages/policies/project_policy.rb
+++ b/app/policies/packages/policies/project_policy.rb
@@ -52,3 +52,5 @@ module Packages
end
end
end
+
+Packages::Policies::ProjectPolicy.prepend_mod_with('Packages::Policies::ProjectPolicy')
diff --git a/app/policies/project_member_policy.rb b/app/policies/project_member_policy.rb
index 40ba30fce5e..bcfc7c87d41 100644
--- a/app/policies/project_member_policy.rb
+++ b/app/policies/project_member_policy.rb
@@ -1,13 +1,18 @@
# frozen_string_literal: true
class ProjectMemberPolicy < BasePolicy
+ include MemberPolicyHelpers
delegate { @subject.project }
condition(:target_is_holder_of_the_personal_namespace, scope: :subject) do
@subject.project.personal_namespace_holder?(@subject.user)
end
- condition(:target_is_self) { @user && @subject.user == @user }
+ desc "Membership is users' own access request"
+ with_score 0
+ condition(:access_request_of_self) { record_is_access_request_of_self? }
+
+ condition(:target_is_self) { record_belongs_to_self? }
condition(:project_bot) { @subject.user&.project_bot? }
rule { anonymous }.prevent_all
@@ -24,5 +29,11 @@ class ProjectMemberPolicy < BasePolicy
rule { project_bot & can?(:admin_project_member) }.enable :destroy_project_bot_member
- rule { target_is_self }.enable :destroy_project_member
+ rule { target_is_self }.policy do
+ enable :destroy_project_member
+ end
+
+ rule { access_request_of_self }.policy do
+ enable :withdraw_member_access_request
+ end
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 77bdf9d62fc..bfeb1a602ab 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -195,8 +195,6 @@ class ProjectPolicy < BasePolicy
with_scope :subject
condition(:packages_disabled) { !@subject.packages_enabled }
- condition(:work_items_enabled, scope: :subject) { project&.work_items_feature_flag_enabled? }
-
features = %w[
merge_requests
issues
@@ -213,6 +211,7 @@ class ProjectPolicy < BasePolicy
environments
feature_flags
releases
+ infrastructure
]
features.each do |f|
@@ -255,7 +254,6 @@ class ProjectPolicy < BasePolicy
enable :change_namespace
enable :change_visibility_level
- enable :rename_project
enable :remove_project
enable :archive_project
enable :remove_fork_project
@@ -303,7 +301,7 @@ class ProjectPolicy < BasePolicy
rule { can?(:create_issue) }.enable :create_work_item
- rule { can?(:create_issue) & work_items_enabled }.enable :create_task
+ rule { can?(:create_issue) }.enable :create_task
# These abilities are not allowed to admins that are not members of the project,
# that's why they are defined separately.
@@ -409,6 +407,14 @@ class ProjectPolicy < BasePolicy
prevent(*create_read_update_admin_destroy(:alert_management_alert))
end
+ rule { split_operations_visibility_permissions & 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)
+ end
+
rule { can?(:metrics_dashboard) }.policy do
enable :read_prometheus
enable :read_deployment
@@ -490,6 +496,7 @@ class ProjectPolicy < BasePolicy
enable :push_to_delete_protected_branch
enable :update_snippet
enable :admin_snippet
+ enable :rename_project
enable :admin_project_member
enable :admin_note
enable :admin_wiki
@@ -530,6 +537,7 @@ class ProjectPolicy < BasePolicy
enable :read_web_hooks
enable :read_upload
enable :destroy_upload
+ enable :admin_incident_management_timeline_event_tag
end
rule { public_project & metrics_dashboard_allowed }.policy do
@@ -624,7 +632,6 @@ class ProjectPolicy < BasePolicy
prevent :read_commit_status
prevent :read_pipeline
prevent :read_pipeline_schedule
- prevent(*create_read_update_admin_destroy(:release))
prevent(*create_read_update_admin_destroy(:feature_flag))
prevent(:admin_feature_flags_user_lists)
end
@@ -729,6 +736,10 @@ class ProjectPolicy < BasePolicy
enable :read_work_item
end
+ rule { can?(:read_merge_request) }.policy do
+ enable :read_vulnerability_merge_request_link
+ end
+
rule { can?(:developer_access) }.policy do
enable :read_security_configuration
end
@@ -827,6 +838,8 @@ class ProjectPolicy < BasePolicy
rule { can?(:admin_project_member) }.policy do
enable :import_project_members_from_another_project
+ # ability to read, approve or reject member access requests of other users
+ enable :admin_member_access_request
end
rule { registry_enabled & can?(:admin_container_image) }.policy do
@@ -837,6 +850,14 @@ class ProjectPolicy < BasePolicy
enable :view_package_registry_project_settings
end
+ rule { can?(:read_project) }.policy do
+ enable :read_incident_management_timeline_event_tag
+ end
+
+ rule { can?(:download_code) }.policy do
+ enable :read_code
+ end
+
private
def user_is_user?
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index f62ccef826c..4f3dafbf5c8 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -36,6 +36,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 :get_user_associations_count
end
UserPolicy.prepend_mod_with('UserPolicy')
diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb
index 74ac47fa439..92dcfeed104 100644
--- a/app/presenters/blob_presenter.rb
+++ b/app/presenters/blob_presenter.rb
@@ -9,6 +9,19 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
presents ::Blob, as: :blob
+ def highlight_and_trim(ellipsis_svg:, trim_length:, plain: nil)
+ load_all_blob_data
+
+ trimmed_lines, trimmed_idx = trimmed_blob_data(trim_length)
+ Gitlab::Highlight.highlight(
+ blob.path,
+ trimmed_lines,
+ language: blob_language,
+ plain: plain,
+ context: { ellipsis_indexes: trimmed_idx, ellipsis_svg: ellipsis_svg }
+ )
+ end
+
def highlight(to: nil, plain: nil)
load_all_blob_data
@@ -26,6 +39,10 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
highlight(plain: false)
end
+ def trimmed_blob_data(trim_length)
+ @_trimmed_blob_data ||= limited_trimmed_blob_data(trim_length)
+ end
+
def blob_data(to)
@_blob_data ||= limited_blob_data(to: to)
end
@@ -169,6 +186,19 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
blob.load_all_data! if blob.respond_to?(:load_all_data!)
end
+ def limited_trimmed_blob_data(trim_length)
+ trimmed_idx = []
+
+ trimmed_lines = all_lines.map.with_index do |line, index|
+ next line if line.length <= trim_length
+
+ trimmed_idx << index
+ "#{line[0, trim_length]}\n"
+ end
+
+ [trimmed_lines.join, trimmed_idx]
+ end
+
def limited_blob_data(to: nil)
return blob.data if to.blank?
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index 706608e3029..7242a80b924 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -33,9 +33,13 @@ module Ci
end
def runner_variables
- stop_expanding_file_vars = ::Feature.enabled?(:ci_stop_expanding_file_vars_for_runners, project)
+ stop_expanding_raw_refs = ::Feature.enabled?(:ci_raw_variables_in_yaml_config, project)
+
variables
- .sort_and_expand_all(keep_undefined: true, expand_file_vars: !stop_expanding_file_vars, project: project)
+ .sort_and_expand_all(keep_undefined: true,
+ expand_file_refs: false,
+ expand_raw_refs: !stop_expanding_raw_refs,
+ project: project)
.to_runner_variables
end
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index fed4ae7837b..aa0cd476191 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -53,7 +53,7 @@ module Ci
}.freeze
end
- def name
+ def event_type_name
# Currently, `merge_request_event_type` is the only source to name pipelines
# but this could be extended with the other types in the future.
localized_names.fetch(pipeline.merge_request_event_type, s_('Pipeline|Pipeline'))
diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb
index 059d6d06be2..f6720546fab 100644
--- a/app/presenters/commit_status_presenter.rb
+++ b/app/presenters/commit_status_presenter.rb
@@ -41,7 +41,7 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
TROUBLESHOOTING_DOC = {
environment_creation_failure: { path: 'ci/environments/index', anchor: 'a-deployment-job-failed-with-this-job-could-not-be-executed-because-it-would-create-an-environment-with-an-invalid-parameter-error' },
- failed_outdated_deployment_job: { path: 'ci/environments/deployment_safety', anchor: 'skip-outdated-deployment-jobs' }
+ failed_outdated_deployment_job: { path: 'ci/environments/deployment_safety', anchor: 'prevent-outdated-deployment-jobs' }
}.freeze
private_constant :CALLOUT_FAILURE_MESSAGES
diff --git a/app/presenters/deployments/deployment_presenter.rb b/app/presenters/deployments/deployment_presenter.rb
index 5ef6fcff974..478c7a85f90 100644
--- a/app/presenters/deployments/deployment_presenter.rb
+++ b/app/presenters/deployments/deployment_presenter.rb
@@ -5,11 +5,14 @@ module Deployments
presents ::Deployment, as: :deployment
delegator_override :tags
+
+ # Note: this returns the path key as 'tags/tag_name' but it is used as a URL in the UI
+
def tags
super.map do |tag|
{
- name: tag,
- path: "tags/#{tag}"
+ name: tag.delete_prefix(Gitlab::Git::TAG_REF_PREFIX),
+ path: tag.delete_prefix('refs/')
}
end
end
diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb
index fc47ece6199..70fe6324712 100644
--- a/app/presenters/release_presenter.rb
+++ b/app/presenters/release_presenter.rb
@@ -4,13 +4,13 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
presents ::Release, as: :release
def commit_path
- return unless release.commit && can_download_code?
+ return unless release.commit && can_read_code?
project_commit_path(project, release.commit.id)
end
def tag_path
- return unless can_download_code?
+ return unless can_read_code?
project_tag_path(project, release.tag)
end
@@ -47,7 +47,7 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
delegator_override :assets_count
def assets_count
- if can_download_code?
+ if can_read_code?
release.assets_count
else
release.assets_count(except: [:sources])
@@ -67,8 +67,8 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
private
- def can_download_code?
- can?(current_user, :download_code, project)
+ def can_read_code?
+ can?(current_user, :read_code, project)
end
def params_for_issues_and_mrs(state: 'opened')
diff --git a/app/serializers/ci/pipeline_entity.rb b/app/serializers/ci/pipeline_entity.rb
index 20aeb978520..143017c5159 100644
--- a/app/serializers/ci/pipeline_entity.rb
+++ b/app/serializers/ci/pipeline_entity.rb
@@ -4,12 +4,13 @@ class Ci::PipelineEntity < Grape::Entity
include RequestAwareEntity
include Gitlab::Utils::StrongMemoize
- delegate :name, :failure_reason, :coverage, to: :presented_pipeline
+ delegate :event_type_name, :failure_reason, :coverage, to: :presented_pipeline
expose :id
expose :iid
expose :user, using: UserEntity
expose :active?, as: :active
+ expose :name, if: -> (pipeline, _) { Feature.enabled?(:pipeline_name, pipeline.project) }
# Coverage isn't always necessary (e.g. when displaying project pipelines in
# the UI). Instead of creating an entirely different entity we just allow the
@@ -40,7 +41,8 @@ class Ci::PipelineEntity < Grape::Entity
expose :stages, using: StageEntity
expose :duration
expose :finished_at
- expose :name
+ expose :event_type_name
+ expose :event_type_name, as: :name # To be removed in 15.7
end
expose :merge_request, if: -> (*) { has_presentable_merge_request? }, with: MergeRequestForPipelineEntity do |pipeline|
diff --git a/app/serializers/codequality_degradation_entity.rb b/app/serializers/codequality_degradation_entity.rb
index 6289260465b..52945a753dc 100644
--- a/app/serializers/codequality_degradation_entity.rb
+++ b/app/serializers/codequality_degradation_entity.rb
@@ -13,4 +13,6 @@ class CodequalityDegradationEntity < Grape::Entity
expose :line do |degradation|
degradation.dig(:location, :lines, :begin) || degradation.dig(:location, :positions, :begin, :line)
end
+
+ expose :web_url
end
diff --git a/app/serializers/detailed_status_entity.rb b/app/serializers/detailed_status_entity.rb
index 4f23ef0ed82..ed8ac9f40f7 100644
--- a/app/serializers/detailed_status_entity.rb
+++ b/app/serializers/detailed_status_entity.rb
@@ -3,12 +3,25 @@
class DetailedStatusEntity < Grape::Entity
include RequestAwareEntity
- expose :icon, :text, :label, :group
- expose :status_tooltip, as: :tooltip
- expose :has_details?, as: :has_details
- expose :details_path
+ expose :icon, documentation: { type: 'string', example: 'status_success' }
+ expose :text, documentation: { type: 'string', example: 'passed' }
+ expose :label, documentation: { type: 'string', example: 'passed' }
+ expose :group, documentation: { type: 'string', example: 'success' }
+ expose :status_tooltip, as: :tooltip, documentation: { type: 'string', example: 'passed' }
+ expose :has_details?, as: :has_details, documentation: { type: 'boolean', example: true }
+ expose :details_path, documentation: { type: 'string', example: '/test-group/test-project/-/pipelines/287' }
- expose :illustration do |status|
+ expose :illustration, documentation: {
+ type: 'object',
+ example: <<~JSON
+ {
+ "image": "illustrations/job_not_triggered.svg",
+ "size": "svg-306",
+ "title": "This job has not been triggered yet",
+ "content": "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+ }
+ JSON
+ } do |status|
illustration = {
image: ActionController::Base.helpers.image_path(status.illustration[:image])
}
@@ -19,15 +32,17 @@ class DetailedStatusEntity < Grape::Entity
# ignored
end
- expose :favicon do |status|
+ expose :favicon,
+ documentation: { type: 'string',
+ example: '/assets/ci_favicons/favicon_status_success.png' } do |status|
Gitlab::Favicon.status_overlay(status.favicon)
end
expose :action, if: -> (status, _) { status.has_action? } do
- expose :action_icon, as: :icon
- expose :action_title, as: :title
- expose :action_path, as: :path
- expose :action_method, as: :method
- expose :action_button_title, as: :button_title
+ expose :action_icon, as: :icon, documentation: { type: 'string', example: 'cancel' }
+ expose :action_title, as: :title, documentation: { type: 'string', example: 'Cancel' }
+ expose :action_path, as: :path, documentation: { type: 'string', example: '/namespace1/project1/-/jobs/2/cancel' }
+ expose :action_method, as: :method, documentation: { type: 'string', example: 'post' }
+ expose :action_button_title, as: :button_title, documentation: { type: 'string', example: 'Cancel this job' }
end
end
diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb
index 9f8628fe849..aa43b9861d3 100644
--- a/app/serializers/diff_file_entity.rb
+++ b/app/serializers/diff_file_entity.rb
@@ -55,17 +55,9 @@ class DiffFileEntity < DiffFileBaseEntity
end
# Used for inline diffs
- expose :highlighted_diff_lines, using: DiffLineEntity, if: -> (diff_file, options) { inline_diff_view?(options) && diff_file.text? } do |diff_file|
- highlighted_diff_lines_for(diff_file, options)
- end
+ expose :diff_lines_for_serializer, as: :highlighted_diff_lines, using: DiffLineEntity, if: -> (diff_file, options) { inline_diff_view?(options) && diff_file.text? }
- expose :is_fully_expanded do |diff_file|
- if conflict_file(options, diff_file)
- false
- else
- diff_file.fully_expanded?
- end
- end
+ expose :fully_expanded?, as: :is_fully_expanded
# Used for parallel diffs
expose :parallel_diff_lines, using: DiffLineParallelEntity, if: -> (diff_file, options) { parallel_diff_view?(options) && diff_file.text? }
@@ -88,15 +80,6 @@ class DiffFileEntity < DiffFileBaseEntity
# If nothing is present, inline will be the default.
options.fetch(:diff_view, :inline).to_sym
end
-
- def highlighted_diff_lines_for(diff_file, options)
- file = conflict_file(options, diff_file) || diff_file
-
- file.diff_lines_for_serializer
- rescue Gitlab::Git::Conflict::Parser::UnmergeableFile
- # Fallback to diff_file as it means that conflict lines can't be parsed due to limit
- diff_file.diff_lines_for_serializer
- end
end
DiffFileEntity.prepend_mod
diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb
index c818fcd6215..759d1e0f10a 100644
--- a/app/serializers/diffs_entity.rb
+++ b/app/serializers/diffs_entity.rb
@@ -74,7 +74,7 @@ class DiffsEntity < Grape::Entity
options.merge(
submodule_links: submodule_links,
code_navigation_path: code_navigation_path(diffs),
- conflicts: conflicts(allow_tree_conflicts: options[:allow_tree_conflicts])
+ conflicts: (conflicts(allow_tree_conflicts: true) if options[:merge_conflicts_in_diff])
)
)
end
diff --git a/app/serializers/diffs_metadata_entity.rb b/app/serializers/diffs_metadata_entity.rb
index 8c226130f6e..ace5105dda5 100644
--- a/app/serializers/diffs_metadata_entity.rb
+++ b/app/serializers/diffs_metadata_entity.rb
@@ -6,7 +6,7 @@ class DiffsMetadataEntity < DiffsEntity
DiffFileMetadataEntity.represent(
diffs.raw_diff_files(sorted: true),
options.merge(
- conflicts: conflicts(allow_tree_conflicts: options[:allow_tree_conflicts])
+ conflicts: (conflicts(allow_tree_conflicts: true) if options[:merge_conflicts_in_diff])
)
)
end
diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb
index 22839ba3099..46d5a488aea 100644
--- a/app/serializers/environment_serializer.rb
+++ b/app/serializers/environment_serializer.rb
@@ -43,7 +43,7 @@ class EnvironmentSerializer < BaseSerializer
# immediately.
items = @paginator.paginate(items) if paginated?
- environments = batch_load(resource.where(id: items.map(&:last_id)))
+ environments = batch_load(Environment.where(id: items.map(&:last_id)))
environments_by_id = environments.index_by(&:id)
items.map do |item|
@@ -52,15 +52,13 @@ class EnvironmentSerializer < BaseSerializer
end
def batch_load(resource)
- temp_deployment_associations = deployment_associations
-
resource = resource.preload(environment_associations)
Preloaders::Environments::DeploymentPreloader.new(resource)
- .execute_with_union(:last_deployment, temp_deployment_associations)
+ .execute_with_union(:last_deployment, deployment_associations)
Preloaders::Environments::DeploymentPreloader.new(resource)
- .execute_with_union(:upcoming_deployment, temp_deployment_associations)
+ .execute_with_union(:upcoming_deployment, deployment_associations)
resource.to_a.tap do |environments|
environments.each do |environment|
diff --git a/app/serializers/group_child_serializer.rb b/app/serializers/group_child_serializer.rb
index 789707c2c9b..54e65752163 100644
--- a/app/serializers/group_child_serializer.rb
+++ b/app/serializers/group_child_serializer.rb
@@ -39,12 +39,13 @@ class GroupChildSerializer < BaseSerializer
def represent_hierarchy(hierarchy, opts)
serializer = self.class.new(params)
- if hierarchy.is_a?(Hash)
+ case hierarchy
+ when Hash
hierarchy.map do |parent, children|
serializer.represent(parent, opts)
.merge(children: Array.wrap(serializer.represent_hierarchy(children, opts)))
end
- elsif hierarchy.is_a?(Array)
+ when Array
hierarchy.flat_map { |child| serializer.represent_hierarchy(child, opts) }
else
serializer.represent(hierarchy, opts)
diff --git a/app/serializers/integrations/event_entity.rb b/app/serializers/integrations/event_entity.rb
index 91bd91dd941..1cbd6114581 100644
--- a/app/serializers/integrations/event_entity.rb
+++ b/app/serializers/integrations/event_entity.rb
@@ -25,6 +25,9 @@ module Integrations
expose :value do |event|
integration.event_channel_value(event)
end
+ expose :placeholder do |_event|
+ integration.default_channel_placeholder
+ end
end
private
diff --git a/app/serializers/integrations/field_entity.rb b/app/serializers/integrations/field_entity.rb
index 697b53a737e..1c548cfab78 100644
--- a/app/serializers/integrations/field_entity.rb
+++ b/app/serializers/integrations/field_entity.rb
@@ -22,6 +22,8 @@ module Integrations
'true'
elsif field[:type] == 'checkbox'
ActiveRecord::Type::Boolean.new.deserialize(value).to_s
+ elsif field[:name] == 'webhook' && integration.chat?
+ BaseChatNotification::SECRET_MASK if value.present?
else
value
end
diff --git a/app/serializers/merge_request_noteable_entity.rb b/app/serializers/merge_request_noteable_entity.rb
index 07d7d19d1f3..306bac7daae 100644
--- a/app/serializers/merge_request_noteable_entity.rb
+++ b/app/serializers/merge_request_noteable_entity.rb
@@ -66,7 +66,7 @@ class MergeRequestNoteableEntity < IssuableEntity
expose :project_id
expose :archived_project_docs_path, if: -> (merge_request) { merge_request.project.archived? } do |merge_request|
- help_page_path('user/project/settings/index.md', anchor: 'archiving-a-project')
+ help_page_path('user/project/settings/index.md', anchor: 'archive-a-project')
end
private
diff --git a/app/serializers/merge_request_poll_cached_widget_entity.rb b/app/serializers/merge_request_poll_cached_widget_entity.rb
index 0c5af67bcda..33079905ed2 100644
--- a/app/serializers/merge_request_poll_cached_widget_entity.rb
+++ b/app/serializers/merge_request_poll_cached_widget_entity.rb
@@ -3,11 +3,9 @@
class MergeRequestPollCachedWidgetEntity < IssuableEntity
include MergeRequestMetricsHelper
- expose :auto_merge_enabled
expose :state
expose :merged_commit_sha
expose :short_merged_commit_sha
- expose :merge_error
expose :merge_user_id
expose :source_branch
expose :source_project_id
@@ -16,15 +14,10 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
expose :target_project_id
expose :squash
expose :rebase_in_progress?, as: :rebase_in_progress
- expose :commits_count
+ expose :default_squash_commit_message
expose :merge_ongoing?, as: :merge_ongoing
- expose :draft?, as: :draft
- expose :draft?, as: :work_in_progress
- expose :cannot_be_merged?, as: :has_conflicts
- expose :can_be_merged?, as: :can_be_merged
expose :remove_source_branch?, as: :remove_source_branch
expose :source_branch_exists?, as: :source_branch_exists
- expose :branch_missing?, as: :branch_missing
expose :merge_status do |merge_request|
merge_request.check_mergeability(async: true)
diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb
index 40bb905c5c9..ab180b35b29 100644
--- a/app/serializers/merge_request_poll_widget_entity.rb
+++ b/app/serializers/merge_request_poll_widget_entity.rb
@@ -11,7 +11,6 @@ class MergeRequestPollWidgetEntity < Grape::Entity
merge_request.source_project.present? && ProtectedBranch.protected?(merge_request.source_project, merge_request.source_branch)
end
expose :allow_collaboration
- expose :should_be_rebased?, as: :should_be_rebased
expose :ff_only_enabled do |merge_request|
merge_request.project.merge_requests_ff_only_enabled
end
@@ -31,15 +30,6 @@ class MergeRequestPollWidgetEntity < Grape::Entity
merge_request.default_merge_commit_message(include_description: true)
end
- # Booleans
- expose :mergeable_discussions_state?, as: :mergeable_discussions_state do |merge_request|
- merge_request.mergeable_discussions_state?
- end
-
- expose :project_archived do |merge_request|
- merge_request.project.archived?
- end
-
expose :only_allow_merge_if_pipeline_succeeds do |merge_request|
merge_request.project.only_allow_merge_if_pipeline_succeeds?
end
diff --git a/app/serializers/merge_requests/pipeline_entity.rb b/app/serializers/merge_requests/pipeline_entity.rb
index f4fb01604d0..76e75a8ca6d 100644
--- a/app/serializers/merge_requests/pipeline_entity.rb
+++ b/app/serializers/merge_requests/pipeline_entity.rb
@@ -5,6 +5,7 @@ class MergeRequests::PipelineEntity < Grape::Entity
expose :id
expose :active?, as: :active
+ expose :name, if: -> (pipeline, _) { Feature.enabled?(:pipeline_name, pipeline.project) }
expose :path do |pipeline|
project_pipeline_path(pipeline.project, pipeline)
@@ -17,8 +18,12 @@ class MergeRequests::PipelineEntity < Grape::Entity
expose :commit, using: CommitEntity
expose :details do
- expose :name do |pipeline|
- pipeline.present.name
+ expose :event_type_name do |pipeline|
+ pipeline.present.event_type_name
+ end
+
+ expose :name do |pipeline| # To be removed in 15.7
+ pipeline.present.event_type_name
end
expose :artifacts do |pipeline, options|
diff --git a/app/serializers/paginated_diff_entity.rb b/app/serializers/paginated_diff_entity.rb
index c656cff9dd7..b79a0937659 100644
--- a/app/serializers/paginated_diff_entity.rb
+++ b/app/serializers/paginated_diff_entity.rb
@@ -17,7 +17,7 @@ class PaginatedDiffEntity < Grape::Entity
options.merge(
submodule_links: submodule_links,
code_navigation_path: code_navigation_path(diffs),
- conflicts: conflicts(allow_tree_conflicts: options[:allow_tree_conflicts])
+ conflicts: (conflicts(allow_tree_conflicts: true) if options[:merge_conflicts_in_diff])
)
)
end
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index 9cfc81e8705..b738438a78f 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -40,6 +40,7 @@ class PipelineSerializer < BaseSerializer
def preloaded_relations
[
+ :pipeline_metadata,
:cancelable_statuses,
:retryable_builds,
:stages,
diff --git a/app/serializers/project_entity.rb b/app/serializers/project_entity.rb
index 60c4ba135d6..77e2115fbe2 100644
--- a/app/serializers/project_entity.rb
+++ b/app/serializers/project_entity.rb
@@ -3,14 +3,14 @@
class ProjectEntity < Grape::Entity
include RequestAwareEntity
- expose :id
- expose :name
+ expose :id, documentation: { type: 'integer', example: 1 }
+ expose :name, documentation: { type: 'string', example: 'GitLab' }
- expose :full_path do |project|
+ expose :full_path, documentation: { type: 'string', example: 'gitlab-org/gitlab' } do |project|
project_path(project)
end
- expose :full_name do |project|
+ expose :full_name, documentation: { type: 'string', example: 'GitLab Org / GitLab' } do |project|
project.full_name
end
end
diff --git a/app/serializers/project_import_entity.rb b/app/serializers/project_import_entity.rb
index 9b51af685e7..a3dbff3dc0b 100644
--- a/app/serializers/project_import_entity.rb
+++ b/app/serializers/project_import_entity.rb
@@ -3,11 +3,13 @@
class ProjectImportEntity < ProjectEntity
include ImportHelper
- expose :import_source
- expose :import_status
- expose :human_import_status_name
+ expose :import_source, documentation: { type: 'string', example: 'source/source-repo' }
+ expose :import_status, documentation: {
+ type: 'string', example: 'scheduled', values: %w[scheduled started finished failed canceled]
+ }
+ expose :human_import_status_name, documentation: { type: 'string', example: 'canceled' }
- expose :provider_link do |project, options|
+ expose :provider_link, documentation: { type: 'string', example: '/source/source-repo' } do |project, options|
provider_project_link_url(options[:provider_url], project[:import_source])
end
end
diff --git a/app/serializers/test_case_entity.rb b/app/serializers/test_case_entity.rb
index 8a5fadf53a6..1a872274cbf 100644
--- a/app/serializers/test_case_entity.rb
+++ b/app/serializers/test_case_entity.rb
@@ -3,15 +3,20 @@
class TestCaseEntity < Grape::Entity
include API::Helpers::RelatedResourcesHelpers
- expose :status
- expose :name, default: "(No name)"
- expose :classname
- expose :file
- expose :execution_time
- expose :system_output
- expose :stack_trace
- expose :recent_failures
- expose :attachment_url, if: -> (*) { can_read_screenshots? } do |test_case|
+ expose :status, documentation: { type: 'string', example: 'success' }
+ expose :name, default: "(No name)",
+ 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 }
+ expose :system_output, documentation: { type: 'string', example: 'Failure/Error: is_expected.to eq(3)' }
+ expose :stack_trace, documentation: { type: 'string', example: 'Failure/Error: is_expected.to eq(3)' }
+ expose :recent_failures, documentation: { example: { count: 3, base_branch: 'develop' } }
+ expose(
+ :attachment_url,
+ if: -> (*) { can_read_screenshots? },
+ documentation: { type: 'string', example: 'http://localhost/namespace1/project1/-/jobs/1/artifacts/file/some/path.png' }
+ ) do |test_case|
expose_url(test_case.attachment_url)
end
diff --git a/app/serializers/test_report_entity.rb b/app/serializers/test_report_entity.rb
index 9eb487da60a..0ff1c671f53 100644
--- a/app/serializers/test_report_entity.rb
+++ b/app/serializers/test_report_entity.rb
@@ -1,15 +1,15 @@
# frozen_string_literal: true
class TestReportEntity < Grape::Entity
- expose :total_time
- expose :total_count
+ expose :total_time, documentation: { type: 'integer', example: 180 }
+ expose :total_count, documentation: { type: 'integer', example: 1 }
- expose :success_count
- expose :failed_count
- expose :skipped_count
- expose :error_count
+ expose :success_count, documentation: { type: 'integer', example: 1 }
+ expose :failed_count, documentation: { type: 'integer', example: 0 }
+ expose :skipped_count, documentation: { type: 'integer', example: 0 }
+ expose :error_count, documentation: { type: 'integer', example: 0 }
- expose :test_suites, using: TestSuiteEntity do |report|
+ expose :test_suites, using: TestSuiteEntity, documentation: { is_array: true } do |report|
report.test_suites.values
end
end
diff --git a/app/serializers/test_report_summary_entity.rb b/app/serializers/test_report_summary_entity.rb
index bc73c49092f..f712b9f5500 100644
--- a/app/serializers/test_report_summary_entity.rb
+++ b/app/serializers/test_report_summary_entity.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class TestReportSummaryEntity < Grape::Entity
- expose :total
+ expose :total, documentation: { type: 'integer', example: 3363 }
expose :test_suites, using: TestSuiteSummaryEntity do |summary|
summary.test_suites.values
diff --git a/app/serializers/test_suite_entity.rb b/app/serializers/test_suite_entity.rb
index 15eb2891b22..a31b1f3ab9b 100644
--- a/app/serializers/test_suite_entity.rb
+++ b/app/serializers/test_suite_entity.rb
@@ -1,18 +1,19 @@
# frozen_string_literal: true
class TestSuiteEntity < Grape::Entity
- expose :name
- expose :total_time
- expose :total_count
+ expose :name, documentation: { type: 'string', example: 'test' }
+ expose :total_time, documentation: { type: 'integer', example: 1904 }
+ expose :total_count, documentation: { type: 'integer', example: 3363 }
- expose :success_count
- expose :failed_count
- expose :skipped_count
- expose :error_count
+ expose :success_count, documentation: { type: 'integer', example: 3351 }
+ expose :failed_count, documentation: { type: 'integer', example: 0 }
+ expose :skipped_count, documentation: { type: 'integer', example: 12 }
+ expose :error_count, documentation: { type: 'integer', example: 0 }
with_options if: -> (_, opts) { opts[:details] } do |test_suite|
- expose :suite_error
- expose :test_cases, using: TestCaseEntity do |test_suite|
+ expose :suite_error,
+ documentation: { type: 'string', example: 'JUnit XML parsing failed: 1:1: FATAL: Document is empty' }
+ expose :test_cases, using: TestCaseEntity, documentation: { is_array: true } do |test_suite|
test_suite.suite_error ? [] : test_suite.sorted.test_cases.values.flat_map(&:values)
end
end
diff --git a/app/serializers/test_suite_summary_entity.rb b/app/serializers/test_suite_summary_entity.rb
index 228c6e499fe..3a9ccb22713 100644
--- a/app/serializers/test_suite_summary_entity.rb
+++ b/app/serializers/test_suite_summary_entity.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
class TestSuiteSummaryEntity < TestSuiteEntity
- expose :build_ids do |summary|
+ expose :build_ids, documentation: { type: 'integer', is_array: true, example: [66004] } do |summary|
summary.build_ids
end
- expose :suite_error
+ expose :suite_error,
+ documentation: { type: 'string', example: 'JUnit XML parsing failed: 1:1: FATAL: Document is empty' }
end
diff --git a/app/services/bulk_imports/create_pipeline_trackers_service.rb b/app/services/bulk_imports/create_pipeline_trackers_service.rb
index f5b944e6df5..7fa62e0ce8a 100644
--- a/app/services/bulk_imports/create_pipeline_trackers_service.rb
+++ b/app/services/bulk_imports/create_pipeline_trackers_service.rb
@@ -55,6 +55,8 @@ module BulkImports
message: 'Pipeline skipped as source instance version not compatible with pipeline',
bulk_import_entity_id: entity.id,
bulk_import_id: entity.bulk_import_id,
+ bulk_import_entity_type: entity.source_type,
+ source_full_path: entity.source_full_path,
pipeline_name: pipeline[:pipeline],
minimum_source_version: minimum_version,
maximum_source_version: maximum_version,
diff --git a/app/services/bulk_imports/lfs_objects_export_service.rb b/app/services/bulk_imports/lfs_objects_export_service.rb
index 1f745201c8a..b3b7cddf2d9 100644
--- a/app/services/bulk_imports/lfs_objects_export_service.rb
+++ b/app/services/bulk_imports/lfs_objects_export_service.rb
@@ -60,7 +60,7 @@ module BulkImports
def write_lfs_json
filepath = File.join(export_path, "#{BulkImports::FileTransfer::ProjectConfig::LFS_OBJECTS_RELATION}.json")
- File.write(filepath, lfs_json.to_json)
+ File.write(filepath, Gitlab::Json.dump(lfs_json))
end
end
end
diff --git a/app/services/ci/after_requeue_job_service.rb b/app/services/ci/after_requeue_job_service.rb
index 9d54207d75d..4374ccd52e0 100644
--- a/app/services/ci/after_requeue_job_service.rb
+++ b/app/services/ci/after_requeue_job_service.rb
@@ -23,8 +23,6 @@ module Ci
# rubocop: disable CodeReuse/ActiveRecord
def dependent_jobs
- return legacy_dependent_jobs unless ::Feature.enabled?(:ci_requeue_with_dag_object_hierarchy, project)
-
ordered_by_dag(
@processable.pipeline.processables
.from_union(needs_dependent_jobs, stage_dependent_jobs)
@@ -50,24 +48,6 @@ module Ci
).descendants
end
- def legacy_skipped_jobs
- @legacy_skipped_jobs ||= @processable.pipeline.processables.skipped
- end
-
- def legacy_dependent_jobs
- ordered_by_dag(
- legacy_stage_dependent_jobs.or(legacy_needs_dependent_jobs).ordered_by_stage.preload(:needs)
- )
- end
-
- def legacy_stage_dependent_jobs
- legacy_skipped_jobs.after_stage(@processable.stage_idx)
- end
-
- def legacy_needs_dependent_jobs
- legacy_skipped_jobs.scheduling_type_dag.with_needs([@processable.name])
- end
-
def ordered_by_dag(jobs)
sorted_job_names = sort_jobs(jobs).each_with_index.to_h
diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb
index 566346a4b09..3d548c824c8 100644
--- a/app/services/ci/archive_trace_service.rb
+++ b/app/services/ci/archive_trace_service.rb
@@ -68,7 +68,7 @@ module Ci
Gitlab::ErrorTracking
.track_and_raise_for_dev_exception(error,
issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/51502',
- job_id: job.id )
+ job_id: job.id)
end
end
end
diff --git a/app/services/ci/build_erase_service.rb b/app/services/ci/build_erase_service.rb
index 8a468e094eb..71b4c5481b3 100644
--- a/app/services/ci/build_erase_service.rb
+++ b/app/services/ci/build_erase_service.rb
@@ -33,9 +33,7 @@ module Ci
attr_reader :build, :current_user
def destroy_artifacts
- # fix_expire_at is false because in this case we want to explicitly delete the job artifacts
- # this flag is a workaround that will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/355833
- Ci::JobArtifacts::DestroyBatchService.new(build.job_artifacts, fix_expire_at: false).execute
+ Ci::JobArtifacts::DestroyBatchService.new(build.job_artifacts).execute
end
def erase_trace!
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 0b49beffcb5..4106dfe0ecc 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -30,6 +30,7 @@ module Ci
Gitlab::Ci::Pipeline::Chain::Limit::Deployments,
Gitlab::Ci::Pipeline::Chain::Validate::External,
Gitlab::Ci::Pipeline::Chain::Populate,
+ Gitlab::Ci::Pipeline::Chain::PopulateMetadata,
Gitlab::Ci::Pipeline::Chain::StopDryRun,
Gitlab::Ci::Pipeline::Chain::EnsureEnvironments,
Gitlab::Ci::Pipeline::Chain::EnsureResourceGroups,
@@ -118,17 +119,6 @@ module Ci
end
# rubocop: enable Metrics/ParameterLists
- def execute!(*args, &block)
- source = args[0]
- params = Hash(args[1])
-
- execute(source, **params, &block).tap do |response|
- unless response.payload.persisted?
- raise CreateError, pipeline.full_error_messages
- end
- end
- end
-
private
def commit
diff --git a/app/services/ci/job_artifacts/destroy_associations_service.rb b/app/services/ci/job_artifacts/destroy_associations_service.rb
index 08d7f7f6f02..794d24eadf2 100644
--- a/app/services/ci/job_artifacts/destroy_associations_service.rb
+++ b/app/services/ci/job_artifacts/destroy_associations_service.rb
@@ -12,7 +12,7 @@ module Ci
def destroy_records
@job_artifacts_relation.each_batch(of: BATCH_SIZE) do |relation|
- service = Ci::JobArtifacts::DestroyBatchService.new(relation, pick_up_at: Time.current, fix_expire_at: false)
+ service = Ci::JobArtifacts::DestroyBatchService.new(relation, pick_up_at: Time.current)
result = service.execute(update_stats: false)
updates = result[:statistics_updates]
diff --git a/app/services/ci/job_artifacts/destroy_batch_service.rb b/app/services/ci/job_artifacts/destroy_batch_service.rb
index 54ec2c671c6..e0307d9bd53 100644
--- a/app/services/ci/job_artifacts/destroy_batch_service.rb
+++ b/app/services/ci/job_artifacts/destroy_batch_service.rb
@@ -17,10 +17,9 @@ module Ci
# +pick_up_at+:: When to pick up for deletion of files
# Returns:
# +Hash+:: A hash with status and destroyed_artifacts_count keys
- def initialize(job_artifacts, pick_up_at: nil, fix_expire_at: fix_expire_at?, skip_projects_on_refresh: false)
+ def initialize(job_artifacts, pick_up_at: nil, skip_projects_on_refresh: false)
@job_artifacts = job_artifacts.with_destroy_preloads.to_a
@pick_up_at = pick_up_at
- @fix_expire_at = fix_expire_at
@skip_projects_on_refresh = skip_projects_on_refresh
end
@@ -32,9 +31,7 @@ module Ci
track_artifacts_undergoing_stats_refresh
end
- # Detect and fix artifacts that had `expire_at` wrongly backfilled by migration
- # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47723
- detect_and_fix_wrongly_expired_artifacts
+ exclude_trace_artifacts
return success(destroyed_artifacts_count: 0, statistics_updates: {}) if @job_artifacts.empty?
@@ -113,55 +110,9 @@ module Ci
end
end
- # This detects and fixes job artifacts that have `expire_at` wrongly backfilled by the migration
- # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47723.
- # These job artifacts will not be deleted and will have their `expire_at` removed.
- #
- # The migration would have backfilled `expire_at`
- # to midnight on the 22nd of the month of the local timezone,
- # storing it as UTC time in the database.
- #
- # If the timezone setting has changed since the migration,
- # the `expire_at` stored in the database could have changed to a different local time other than midnight.
- # For example:
- # - changing timezone from UTC+02:00 to UTC+02:30 would change the `expire_at` in local time 00:00:00 to 00:30:00.
- # - changing timezone from UTC+00:00 to UTC-01:00 would change the `expire_at` in local time 00:00:00 to 23:00:00 on the previous day (21st).
- #
- # Therefore job artifacts that have `expire_at` exactly on the 00, 30 or 45 minute mark
- # on the dates 21, 22, 23 of the month will not be deleted.
- # https://en.wikipedia.org/wiki/List_of_UTC_time_offsets
- def detect_and_fix_wrongly_expired_artifacts
- return unless @fix_expire_at
-
- wrongly_expired_artifacts, @job_artifacts = @job_artifacts.partition { |artifact| wrongly_expired?(artifact) }
-
- remove_expire_at(wrongly_expired_artifacts) if wrongly_expired_artifacts.any?
- end
-
- def fix_expire_at?
- Feature.enabled?(:ci_detect_wrongly_expired_artifacts)
- end
-
- def wrongly_expired?(artifact)
- return false unless artifact.expire_at.present?
-
- # Although traces should never have expiration dates that don't match time & date here.
- # we can explicitly exclude them by type since they should never be destroyed.
- artifact.trace? || (match_date?(artifact.expire_at) && match_time?(artifact.expire_at))
- end
-
- def match_date?(expire_at)
- [21, 22, 23].include?(expire_at.day)
- end
-
- def match_time?(expire_at)
- %w[00:00.000 30:00.000 45:00.000].include?(expire_at.strftime('%M:%S.%L'))
- end
-
- def remove_expire_at(artifacts)
- Ci::JobArtifact.id_in(artifacts).update_all(expire_at: nil)
-
- Gitlab::AppLogger.info(message: "Fixed expire_at from artifacts.", fixed_artifacts_expire_at_count: artifacts.count)
+ # Traces should never be destroyed.
+ def exclude_trace_artifacts
+ _trace_artifacts, @job_artifacts = @job_artifacts.partition(&:trace?)
end
def track_artifacts_undergoing_stats_refresh
diff --git a/app/services/ci/job_artifacts/track_artifact_report_service.rb b/app/services/ci/job_artifacts/track_artifact_report_service.rb
index 1be1d98394f..0230a5e19ce 100644
--- a/app/services/ci/job_artifacts/track_artifact_report_service.rb
+++ b/app/services/ci/job_artifacts/track_artifact_report_service.rb
@@ -5,7 +5,7 @@ module Ci
class TrackArtifactReportService
include Gitlab::Utils::UsageData
- REPORT_TRACKED = %i[test].freeze
+ REPORT_TRACKED = %i[test coverage].freeze
def execute(pipeline)
REPORT_TRACKED.each do |report|
diff --git a/app/services/ci/list_config_variables_service.rb b/app/services/ci/list_config_variables_service.rb
index 3890882b3d4..df4963d1b33 100644
--- a/app/services/ci/list_config_variables_service.rb
+++ b/app/services/ci/list_config_variables_service.rb
@@ -30,7 +30,7 @@ module Ci
user: current_user,
sha: sha).execute
- result.valid? ? result.variables_with_data : {}
+ result.valid? ? result.root_variables_with_prefill_data : {}
end
# Required for ReactiveCaching, it is also used in `reactive_cache_worker_finder`
diff --git a/app/services/ci/pipeline_artifacts/coverage_report_service.rb b/app/services/ci/pipeline_artifacts/coverage_report_service.rb
index 9c6fdb7a405..de66a4cb045 100644
--- a/app/services/ci/pipeline_artifacts/coverage_report_service.rb
+++ b/app/services/ci/pipeline_artifacts/coverage_report_service.rb
@@ -39,7 +39,7 @@ module Ci
def carrierwave_file
strong_memoize(:carrier_wave_file) do
CarrierWaveStringFile.new_file(
- file_content: report.to_json,
+ file_content: Gitlab::Json.dump(report),
filename: Ci::PipelineArtifact::DEFAULT_FILE_NAMES.fetch(:code_coverage),
content_type: 'application/json'
)
diff --git a/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb b/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb
index a0746ef32b2..57b663dc293 100644
--- a/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb
+++ b/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb
@@ -77,9 +77,9 @@ module Ci
end
def build_quality_mr_diff_report(mr_diff_report)
- mr_diff_report.each_with_object({}) do |diff_report, hash|
+ Gitlab::Json.dump(mr_diff_report.each_with_object({}) do |diff_report, hash|
hash[diff_report.first] = Ci::CodequalityMrDiffReportSerializer.new.represent(diff_report.second) # rubocop: disable CodeReuse/Serializer
- end.to_json
+ end)
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
new file mode 100644
index 00000000000..9b4001c74bd
--- /dev/null
+++ b/app/services/ci/pipeline_schedules/take_ownership_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Ci
+ module PipelineSchedules
+ class TakeOwnershipService
+ def initialize(schedule, user)
+ @schedule = schedule
+ @user = user
+ end
+
+ def execute
+ return forbidden unless allowed?
+
+ if schedule.update(owner: user)
+ ServiceResponse.success(payload: schedule)
+ else
+ ServiceResponse.error(message: schedule.errors.full_messages)
+ end
+ end
+
+ private
+
+ attr_reader :schedule, :user
+
+ def allowed?
+ user.can?(:take_ownership_pipeline_schedule, schedule)
+ end
+
+ def forbidden
+ ServiceResponse.error(message: _('Failed to change the owner'), reason: :access_denied)
+ end
+ end
+ end
+end
diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb
index 39ac9bf33e9..d7065680053 100644
--- a/app/services/ci/pipeline_trigger_service.rb
+++ b/app/services/ci/pipeline_trigger_service.rb
@@ -93,7 +93,7 @@ module Ci
def payload_variable
{ key: PAYLOAD_VARIABLE_KEY,
- value: params.except(*PAYLOAD_VARIABLE_HIDDEN_PARAMS).to_json,
+ value: Gitlab::Json.dump(params.except(*PAYLOAD_VARIABLE_HIDDEN_PARAMS)),
variable_type: :file }
end
diff --git a/app/services/ci/play_build_service.rb b/app/services/ci/play_build_service.rb
index fbf2aad1991..b7aec57f3e3 100644
--- a/app/services/ci/play_build_service.rb
+++ b/app/services/ci/play_build_service.rb
@@ -5,21 +5,27 @@ module Ci
def execute(build, job_variables_attributes = nil)
check_access!(build, job_variables_attributes)
- # Try to enqueue the build, otherwise create a duplicate.
- #
- if build.enqueue
- build.tap do |build|
- build.update!(user: current_user, job_variables_attributes: job_variables_attributes || [])
-
- AfterRequeueJobService.new(project, current_user).execute(build)
- end
+ if build.can_enqueue?
+ build.user = current_user
+ build.job_variables_attributes = job_variables_attributes || []
+ build.enqueue!
+
+ AfterRequeueJobService.new(project, current_user).execute(build)
+
+ build
else
- Ci::RetryJobService.new(project, current_user).execute(build)[:job]
+ retry_build(build)
end
+ rescue StateMachines::InvalidTransition
+ retry_build(build.reset)
end
private
+ def retry_build(build)
+ Ci::RetryJobService.new(project, current_user).execute(build)[:job]
+ end
+
def check_access!(build, job_variables_attributes)
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :play_job, build)
diff --git a/app/services/ci/process_build_service.rb b/app/services/ci/process_build_service.rb
index 22cd267806d..cb51d918fc2 100644
--- a/app/services/ci/process_build_service.rb
+++ b/app/services/ci/process_build_service.rb
@@ -15,6 +15,8 @@ module Ci
private
def process(build)
+ return enqueue(build) if Feature.enabled?(:ci_retry_job_fix, project) && build.enqueue_immediately?
+
if build.schedulable?
build.schedule
elsif build.action?
@@ -25,7 +27,7 @@ module Ci
end
def enqueue(build)
- return build.drop!(:failed_outdated_deployment_job) if build.prevent_rollback_deployment?
+ return build.drop!(:failed_outdated_deployment_job) if build.outdated_deployment?
build.enqueue
end
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index 0bd4bf8cc86..f11577feb88 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -42,7 +42,7 @@ module Ci
if !db_all_caught_up && !result.build
metrics.increment_queue_operation(:queue_replication_lag)
- ::Ci::RegisterJobService::Result.new(nil, false) # rubocop:disable Cop/AvoidReturnFromBlocks
+ ::Ci::RegisterJobService::Result.new(nil, nil, false) # rubocop:disable Cop/AvoidReturnFromBlocks
else
result
end
@@ -226,7 +226,7 @@ module Ci
log_artifacts_context(build)
log_build_dependencies_size(presented_build)
- build_json = ::API::Entities::Ci::JobRequest::Response.new(presented_build).to_json
+ build_json = Gitlab::Json.dump(::API::Entities::Ci::JobRequest::Response.new(presented_build))
Result.new(build, build_json, true)
end
diff --git a/app/services/ci/retry_job_service.rb b/app/services/ci/retry_job_service.rb
index 25bda8a6380..74ebaef48b1 100644
--- a/app/services/ci/retry_job_service.rb
+++ b/app/services/ci/retry_job_service.rb
@@ -19,7 +19,7 @@ module Ci
end
# rubocop: disable CodeReuse/ActiveRecord
- def clone!(job, variables: [])
+ def clone!(job, variables: [], enqueue_if_actionable: false)
# Cloning a job requires a strict type check to ensure
# the attributes being used for the clone are taken straight
# from the model and not overridden by other abstractions.
@@ -28,6 +28,9 @@ module Ci
check_access!(job)
new_job = job.clone(current_user: current_user, new_job_variables_attributes: variables)
+ if Feature.enabled?(:ci_retry_job_fix, project) && enqueue_if_actionable && new_job.action?
+ new_job.set_enqueue_immediately!
+ end
new_job.run_after_commit do
::Ci::CopyCrossDatabaseAssociationsService.new.execute(job, new_job)
@@ -56,13 +59,20 @@ module Ci
def check_assignable_runners!(job); end
def retry_job(job, variables: [])
- clone!(job, variables: variables).tap do |new_job|
+ clone!(job, variables: variables, enqueue_if_actionable: true).tap do |new_job|
check_assignable_runners!(new_job) if new_job.is_a?(Ci::Build)
next if new_job.failed?
- Gitlab::OptimisticLocking.retry_lock(new_job, name: 'retry_build', &:enqueue)
+ Gitlab::OptimisticLocking.retry_lock(new_job, name: 'retry_build', &:enqueue) if Feature.disabled?(
+ :ci_retry_job_fix, project)
+
AfterRequeueJobService.new(project, current_user).execute(job)
+
+ if Feature.enabled?(:ci_retry_job_fix, project)
+ Ci::PipelineCreation::StartPipelineService.new(job.pipeline).execute
+ new_job.reset
+ end
end
end
diff --git a/app/services/ci/runners/bulk_delete_runners_service.rb b/app/services/ci/runners/bulk_delete_runners_service.rb
index ce07aa541c2..b6b07746e61 100644
--- a/app/services/ci/runners/bulk_delete_runners_service.rb
+++ b/app/services/ci/runners/bulk_delete_runners_service.rb
@@ -7,29 +7,69 @@ module Ci
RUNNER_LIMIT = 50
- # @param runners [Array<Ci::Runner, Integer>] the runners to unregister/destroy
- def initialize(runners:)
+ # @param runners [Array<Ci::Runner>] the runners to unregister/destroy
+ # @param current_user [User] the user performing the operation
+ def initialize(runners:, current_user:)
@runners = runners
+ @current_user = current_user
end
def execute
if @runners
# Delete a few runners immediately
- return ServiceResponse.success(payload: delete_runners)
+ return delete_runners
end
- ServiceResponse.success(payload: { deleted_count: 0, deleted_ids: [] })
+ ServiceResponse.success(payload: { deleted_count: 0, deleted_ids: [], errors: [] })
end
private
def delete_runners
+ runner_count = @runners.limit(RUNNER_LIMIT + 1).count
+ authorized_runners_ids, unauthorized_runners_ids = compute_authorized_runners
# rubocop:disable CodeReuse/ActiveRecord
- runners_to_be_deleted = Ci::Runner.where(id: @runners).limit(RUNNER_LIMIT)
+ runners_to_be_deleted =
+ Ci::Runner
+ .where(id: authorized_runners_ids)
+ .preload([:taggings, :runner_namespaces, :runner_projects])
# rubocop:enable CodeReuse/ActiveRecord
- deleted_ids = runners_to_be_deleted.destroy_all.map(&:id) # rubocop: disable Cop/DestroyAll
+ deleted_ids = runners_to_be_deleted.destroy_all.map(&:id) # rubocop:disable Cop/DestroyAll
- { deleted_count: deleted_ids.count, deleted_ids: deleted_ids }
+ ServiceResponse.success(
+ payload: {
+ deleted_count: deleted_ids.count,
+ deleted_ids: deleted_ids,
+ errors: error_messages(runner_count, authorized_runners_ids, unauthorized_runners_ids)
+ })
+ end
+
+ def compute_authorized_runners
+ # rubocop:disable CodeReuse/ActiveRecord
+ @current_user.ci_owned_runners.load # preload the owned runners to avoid an N+1
+ authorized_runners, unauthorized_runners =
+ @runners.limit(RUNNER_LIMIT)
+ .partition { |runner| Ability.allowed?(@current_user, :delete_runner, runner) }
+ # rubocop:enable CodeReuse/ActiveRecord
+
+ [authorized_runners.map(&:id), unauthorized_runners.map(&:id)]
+ end
+
+ def error_messages(runner_count, authorized_runners_ids, unauthorized_runners_ids)
+ errors = []
+
+ if runner_count > RUNNER_LIMIT
+ errors << "Can only delete up to #{RUNNER_LIMIT} runners per call. Ignored the remaining runner(s)."
+ end
+
+ if authorized_runners_ids.empty?
+ errors << "User does not have permission to delete any of the runners"
+ elsif unauthorized_runners_ids.any?
+ failed_ids = unauthorized_runners_ids.map { |runner_id| "##{runner_id}" }.join(', ')
+ errors << "User does not have permission to delete runner(s) #{failed_ids}"
+ end
+
+ errors
end
end
end
diff --git a/app/services/ci/runners/set_runner_associated_projects_service.rb b/app/services/ci/runners/set_runner_associated_projects_service.rb
index 7930776749d..5e33fdae2f4 100644
--- a/app/services/ci/runners/set_runner_associated_projects_service.rb
+++ b/app/services/ci/runners/set_runner_associated_projects_service.rb
@@ -17,7 +17,7 @@ module Ci
return ServiceResponse.error(message: 'user not allowed to assign runner', http_status: :forbidden)
end
- return ServiceResponse.success if project_ids.blank?
+ return ServiceResponse.success if project_ids.nil?
set_associated_projects
end
diff --git a/app/services/clusters/applications/check_ingress_ip_address_service.rb b/app/services/clusters/applications/check_ingress_ip_address_service.rb
deleted file mode 100644
index e254a0358a0..00000000000
--- a/app/services/clusters/applications/check_ingress_ip_address_service.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Applications
- class CheckIngressIpAddressService < BaseHelmService
- include Gitlab::Utils::StrongMemoize
-
- Error = Class.new(StandardError)
-
- LEASE_TIMEOUT = 15.seconds.to_i
-
- def execute
- return if app.external_ip
- return if app.external_hostname
- return unless try_obtain_lease
-
- app.external_ip = ingress_ip if ingress_ip
- app.external_hostname = ingress_hostname if ingress_hostname
-
- app.save! if app.changed?
- end
-
- private
-
- def try_obtain_lease
- Gitlab::ExclusiveLease
- .new("check_ingress_ip_address_service:#{app.id}", timeout: LEASE_TIMEOUT)
- .try_obtain
- end
-
- def ingress_ip
- ingress_service&.ip
- end
-
- def ingress_hostname
- ingress_service&.hostname
- end
-
- def ingress_service
- strong_memoize(:ingress_service) do
- app.ingress_service.status.loadBalancer.ingress&.first
- end
- end
- end
- end
-end
diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb
deleted file mode 100644
index 10a12f30956..00000000000
--- a/app/services/clusters/applications/check_installation_progress_service.rb
+++ /dev/null
@@ -1,42 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Applications
- class CheckInstallationProgressService < CheckProgressService
- private
-
- def operation_in_progress?
- app.installing? || app.updating?
- end
-
- def on_success
- app.make_installed!
-
- Gitlab::Tracking.event('cluster:applications', "cluster_application_#{app.name}_installed")
- ensure
- remove_installation_pod
- end
-
- def check_timeout
- if timed_out?
- app.make_errored!("Operation timed out. Check pod logs for #{pod_name} for more details.")
- else
- ClusterWaitForAppInstallationWorker.perform_in(
- ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
- end
- end
-
- def pod_name
- install_command.pod_name
- end
-
- def timed_out?
- Time.current.utc - app.updated_at.utc > ClusterWaitForAppInstallationWorker::TIMEOUT
- end
-
- def remove_installation_pod
- helm_api.delete_pod!(pod_name)
- end
- end
- end
-end
diff --git a/app/services/clusters/applications/check_uninstall_progress_service.rb b/app/services/clusters/applications/check_uninstall_progress_service.rb
deleted file mode 100644
index cd213c3ebbf..00000000000
--- a/app/services/clusters/applications/check_uninstall_progress_service.rb
+++ /dev/null
@@ -1,42 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Applications
- class CheckUninstallProgressService < CheckProgressService
- private
-
- def operation_in_progress?
- app.uninstalling?
- end
-
- def on_success
- app.post_uninstall
- app.destroy!
- rescue StandardError => e
- app.make_errored!(_('Application uninstalled but failed to destroy: %{error_message}') % { error_message: e.message })
- ensure
- remove_uninstallation_pod
- end
-
- def check_timeout
- if timed_out?
- app.make_errored!(_('Operation timed out. Check pod logs for %{pod_name} for more details.') % { pod_name: pod_name })
- else
- WaitForUninstallAppWorker.perform_in(WaitForUninstallAppWorker::INTERVAL, app.name, app.id)
- end
- end
-
- def pod_name
- app.uninstall_command.pod_name
- end
-
- def timed_out?
- Time.current.utc - app.updated_at.utc > WaitForUninstallAppWorker::TIMEOUT
- end
-
- def remove_uninstallation_pod
- helm_api.delete_pod!(pod_name)
- end
- end
- end
-end
diff --git a/app/services/clusters/applications/check_upgrade_progress_service.rb b/app/services/clusters/applications/check_upgrade_progress_service.rb
deleted file mode 100644
index c4fd234b302..00000000000
--- a/app/services/clusters/applications/check_upgrade_progress_service.rb
+++ /dev/null
@@ -1,71 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Applications
- class CheckUpgradeProgressService < BaseHelmService
- def execute
- return unless app.updating?
-
- case phase
- when ::Gitlab::Kubernetes::Pod::SUCCEEDED
- on_success
- when ::Gitlab::Kubernetes::Pod::FAILED
- on_failed
- else
- check_timeout
- end
- rescue ::Kubeclient::HttpError => e
- app.make_update_errored!("Kubernetes error: #{e.message}") unless app.update_errored?
- end
-
- private
-
- def on_success
- app.make_installed!
- ensure
- remove_pod
- end
-
- def on_failed
- app.make_update_errored!(errors || 'Update silently failed')
- ensure
- remove_pod
- end
-
- def check_timeout
- if timed_out?
- begin
- app.make_update_errored!('Update timed out')
- ensure
- remove_pod
- end
- else
- ::ClusterWaitForAppUpdateWorker.perform_in(
- ::ClusterWaitForAppUpdateWorker::INTERVAL, app.name, app.id)
- end
- end
-
- def timed_out?
- Time.current.utc - app.updated_at.to_time.utc > ::ClusterWaitForAppUpdateWorker::TIMEOUT
- end
-
- def remove_pod
- helm_api.delete_pod!(pod_name)
- rescue StandardError
- # no-op
- end
-
- def phase
- helm_api.status(pod_name)
- end
-
- def errors
- helm_api.log(pod_name)
- end
-
- def pod_name
- @pod_name ||= patch_command.pod_name
- end
- end
- end
-end
diff --git a/app/services/clusters/applications/create_service.rb b/app/services/clusters/applications/create_service.rb
deleted file mode 100644
index 2a626a402e4..00000000000
--- a/app/services/clusters/applications/create_service.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Applications
- class CreateService < Clusters::Applications::BaseService
- private
-
- def worker_class(application)
- application.updateable? ? ClusterUpgradeAppWorker : ClusterInstallAppWorker
- end
-
- def builder
- cluster.public_send(application_class.association_name) || # rubocop:disable GitlabSecurity/PublicSend
- cluster.public_send(:"build_application_#{application_name}") # rubocop:disable GitlabSecurity/PublicSend
- end
- end
- end
-end
diff --git a/app/services/clusters/applications/patch_service.rb b/app/services/clusters/applications/patch_service.rb
deleted file mode 100644
index fbea18bae6b..00000000000
--- a/app/services/clusters/applications/patch_service.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Applications
- class PatchService < BaseHelmService
- def execute
- return unless app.scheduled?
-
- app.make_updating!
-
- patch
- end
-
- private
-
- def patch
- log_event(:begin_patch)
- helm_api.update(update_command)
-
- log_event(:schedule_wait_for_patch)
- ClusterWaitForAppInstallationWorker.perform_in(
- ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
- rescue Kubeclient::HttpError => e
- log_error(e)
- app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code })
- rescue StandardError => e
- log_error(e)
- app.make_errored!(_('Failed to update.'))
- end
- end
- end
-end
diff --git a/app/services/clusters/applications/prometheus_update_service.rb b/app/services/clusters/applications/prometheus_update_service.rb
deleted file mode 100644
index b8b50f06d72..00000000000
--- a/app/services/clusters/applications/prometheus_update_service.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Applications
- # Deprecated, to be removed in %14.0 as part of https://gitlab.com/groups/gitlab-org/-/epics/4280
- class PrometheusUpdateService < BaseHelmService
- attr_accessor :project
-
- def initialize(app, project)
- super(app)
- @project = project
- end
-
- def execute
- raise NotImplementedError, 'Externally installed prometheus should not be modified!' unless app.managed_prometheus?
-
- app.make_updating!
-
- helm_api.update(patch_command(values))
-
- ::ClusterWaitForAppUpdateWorker.perform_in(::ClusterWaitForAppUpdateWorker::INTERVAL, app.name, app.id)
- rescue ::Kubeclient::HttpError => ke
- app.make_update_errored!("Kubernetes error: #{ke.message}")
- rescue StandardError => e
- app.make_update_errored!(e.message)
- end
-
- private
-
- def values
- PrometheusConfigService
- .new(project, cluster, app)
- .execute
- .to_yaml
- end
- end
- end
-end
diff --git a/app/services/clusters/applications/update_service.rb b/app/services/clusters/applications/update_service.rb
deleted file mode 100644
index 7a36401f156..00000000000
--- a/app/services/clusters/applications/update_service.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Applications
- class UpdateService < Clusters::Applications::BaseService
- private
-
- def worker_class(application)
- ClusterPatchAppWorker
- end
-
- def builder
- cluster.public_send(application_class.association_name) # rubocop:disable GitlabSecurity/PublicSend
- end
- end
- end
-end
diff --git a/app/services/clusters/kubernetes/configure_istio_ingress_service.rb b/app/services/clusters/kubernetes/configure_istio_ingress_service.rb
deleted file mode 100644
index 3b7e094bc97..00000000000
--- a/app/services/clusters/kubernetes/configure_istio_ingress_service.rb
+++ /dev/null
@@ -1,112 +0,0 @@
-# frozen_string_literal: true
-
-require 'openssl'
-
-module Clusters
- module Kubernetes
- class ConfigureIstioIngressService
- PASSTHROUGH_RESOURCE = Kubeclient::Resource.new(
- mode: 'PASSTHROUGH'
- ).freeze
-
- MTLS_RESOURCE = Kubeclient::Resource.new(
- mode: 'MUTUAL',
- privateKey: '/etc/istio/ingressgateway-certs/tls.key',
- serverCertificate: '/etc/istio/ingressgateway-certs/tls.crt',
- caCertificates: '/etc/istio/ingressgateway-ca-certs/cert.pem'
- ).freeze
-
- def initialize(cluster:)
- @cluster = cluster
- @platform = cluster.platform
- @kubeclient = platform.kubeclient
- @knative = cluster.application_knative
- end
-
- def execute
- return configure_certificates if serverless_domain_cluster
-
- configure_passthrough
- rescue Kubeclient::HttpError => e
- knative.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code })
- rescue StandardError
- knative.make_errored!(_('Failed to update.'))
- end
-
- private
-
- attr_reader :cluster, :platform, :kubeclient, :knative
-
- def serverless_domain_cluster
- knative&.serverless_domain_cluster
- end
-
- def configure_certificates
- create_or_update_istio_cert_and_key
- set_gateway_wildcard_https(MTLS_RESOURCE)
- end
-
- def create_or_update_istio_cert_and_key
- name = OpenSSL::X509::Name.parse("CN=#{knative.hostname}")
-
- key = OpenSSL::PKey::RSA.new(2048)
-
- cert = OpenSSL::X509::Certificate.new
- cert.version = 2
- cert.serial = 0
- cert.not_before = Time.current
- cert.not_after = Time.current + 1000.years
-
- cert.public_key = key.public_key
- cert.subject = name
- cert.issuer = name
- cert.sign(key, OpenSSL::Digest.new('SHA256'))
-
- serverless_domain_cluster.update!(
- key: key.to_pem,
- certificate: cert.to_pem
- )
-
- kubeclient.create_or_update_secret(istio_ca_certs_resource)
- kubeclient.create_or_update_secret(istio_certs_resource)
- end
-
- def istio_ca_certs_resource
- Gitlab::Kubernetes::GenericSecret.new(
- 'istio-ingressgateway-ca-certs',
- {
- 'cert.pem': Base64.strict_encode64(serverless_domain_cluster.certificate)
- },
- Clusters::Kubernetes::ISTIO_SYSTEM_NAMESPACE
- ).generate
- end
-
- def istio_certs_resource
- Gitlab::Kubernetes::TlsSecret.new(
- 'istio-ingressgateway-certs',
- serverless_domain_cluster.certificate,
- serverless_domain_cluster.key,
- Clusters::Kubernetes::ISTIO_SYSTEM_NAMESPACE
- ).generate
- end
-
- def set_gateway_wildcard_https(tls_resource)
- gateway_resource = gateway
- gateway_resource.spec.servers.each do |server|
- next unless server.hosts == ['*'] && server.port.name == 'https'
-
- server.tls = tls_resource
- end
- kubeclient.update_gateway(gateway_resource)
- end
-
- def configure_passthrough
- set_gateway_wildcard_https(PASSTHROUGH_RESOURCE)
- end
-
- def gateway
- kubeclient.get_gateway('knative-ingress-gateway', Clusters::Kubernetes::KNATIVE_SERVING_NAMESPACE)
- end
- end
- end
-end
diff --git a/app/services/concerns/alert_management/responses.rb b/app/services/concerns/alert_management/responses.rb
index 183a831a00a..e48d07d26c0 100644
--- a/app/services/concerns/alert_management/responses.rb
+++ b/app/services/concerns/alert_management/responses.rb
@@ -7,6 +7,10 @@ module AlertManagement
ServiceResponse.success(payload: { alerts: Array(alerts) })
end
+ def created
+ ServiceResponse.success(http_status: :created)
+ end
+
def bad_request
ServiceResponse.error(message: 'Bad Request', http_status: :bad_request)
end
diff --git a/app/services/dependency_proxy/find_cached_manifest_service.rb b/app/services/dependency_proxy/find_cached_manifest_service.rb
index faf0402edaa..ea09445584a 100644
--- a/app/services/dependency_proxy/find_cached_manifest_service.rb
+++ b/app/services/dependency_proxy/find_cached_manifest_service.rb
@@ -19,6 +19,7 @@ module DependencyProxy
head_result = DependencyProxy::HeadManifestService.new(@image, @tag, @token).execute
return respond if cached_manifest_matches?(head_result)
+ return respond if @manifest && head_result[:status] == :error
success(manifest: nil, from_cache: false)
rescue Timeout::Error, *Gitlab::HTTP::HTTP_ERRORS
diff --git a/app/services/deployments/create_for_build_service.rb b/app/services/deployments/create_for_build_service.rb
index 76d871161e3..7bc0ea88910 100644
--- a/app/services/deployments/create_for_build_service.rb
+++ b/app/services/deployments/create_for_build_service.rb
@@ -8,28 +8,62 @@ module Deployments
def execute(build)
return unless build.instance_of?(::Ci::Build) && build.persisted_environment.present?
- # TODO: Move all buisness logic in `Seed::Deployment` to this class after
- # `create_deployment_in_separate_transaction` feature flag has been removed.
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/348778
-
- # If build.persisted_environment is a BatchLoader, we need to remove
- # the method proxy in order to clone into new item here
- # https://github.com/exAspArk/batch-loader/issues/31
- environment = if build.persisted_environment.respond_to?(:__sync)
- build.persisted_environment.__sync
- else
- build.persisted_environment
- end
-
- deployment = ::Gitlab::Ci::Pipeline::Seed::Deployment
- .new(build, environment).to_resource
+ environment = build.actual_persisted_environment
+
+ deployment = to_resource(build, environment)
return unless deployment
- build.create_deployment!(deployment.attributes)
+ deployment.save!
+ build.association(:deployment).target = deployment
+ build.association(:deployment).loaded!
+
+ deployment
rescue ActiveRecord::RecordInvalid => e
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(
DeploymentCreationError.new(e.message), build_id: build.id)
end
+
+ private
+
+ def to_resource(build, environment)
+ return build.deployment if build.deployment
+ return unless build.starts_environment?
+
+ deployment = ::Deployment.new(attributes(build, environment))
+
+ # If there is a validation error on environment creation, such as
+ # the name contains invalid character, the job will fall back to a
+ # non-environment job.
+ return unless deployment.valid? && deployment.environment.persisted?
+
+ if cluster = deployment.environment.deployment_platform&.cluster
+ # double write cluster_id until 12.9: https://gitlab.com/gitlab-org/gitlab/issues/202628
+ deployment.cluster_id = cluster.id
+ deployment.deployment_cluster = ::DeploymentCluster.new(
+ cluster_id: cluster.id,
+ kubernetes_namespace: cluster.kubernetes_namespace_for(deployment.environment, deployable: build)
+ )
+ end
+
+ # Allocate IID for deployments.
+ # This operation must be outside of transactions of pipeline creations.
+ deployment.ensure_project_iid!
+
+ deployment
+ end
+
+ def attributes(build, environment)
+ {
+ project: build.project,
+ environment: environment,
+ deployable: build,
+ user: build.user,
+ ref: build.ref,
+ tag: build.tag,
+ sha: build.sha,
+ on_stop: build.on_stop
+ }
+ end
end
end
diff --git a/app/services/environments/create_for_build_service.rb b/app/services/environments/create_for_build_service.rb
new file mode 100644
index 00000000000..c46b66ac5b3
--- /dev/null
+++ b/app/services/environments/create_for_build_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Environments
+ # This class creates an environment record for a build (a pipeline job).
+ class CreateForBuildService
+ def execute(build, merge_request: nil)
+ return unless build.instance_of?(::Ci::Build) && build.has_environment_keyword?
+
+ environment = to_resource(build, merge_request)
+
+ if environment.persisted?
+ build.persisted_environment = environment
+ build.assign_attributes(metadata_attributes: { expanded_environment_name: environment.name })
+ else
+ build.assign_attributes(status: :failed, failure_reason: :environment_creation_failure)
+ end
+
+ environment
+ end
+
+ private
+
+ # rubocop: disable Performance/ActiveRecordSubtransactionMethods
+ def to_resource(build, merge_request)
+ build.project.environments.safe_find_or_create_by(name: build.expanded_environment_name) do |environment|
+ # Initialize the attributes at creation
+ environment.auto_stop_in = expanded_auto_stop_in(build)
+ environment.tier = build.environment_tier_from_options
+ environment.merge_request = merge_request
+ end
+ end
+ # rubocop: enable Performance/ActiveRecordSubtransactionMethods
+
+ def expanded_auto_stop_in(build)
+ return unless build.environment_auto_stop_in
+
+ ExpandVariables.expand(build.environment_auto_stop_in, -> { build.simple_variables.sort_and_expand_all })
+ end
+ end
+end
diff --git a/app/services/environments/schedule_to_delete_review_apps_service.rb b/app/services/environments/schedule_to_delete_review_apps_service.rb
index b3b86689748..041b834f11b 100644
--- a/app/services/environments/schedule_to_delete_review_apps_service.rb
+++ b/app/services/environments/schedule_to_delete_review_apps_service.rb
@@ -58,7 +58,7 @@ module Environments
else
result.set_status(
:bad_request,
- error_message: "Failed to authorize deletions for some or all of the environments. Ask someone with more permissions to delete the environments."
+ error_message: "No environments found for scheduled deletion. Either your query did not match any environments (default parameters match environments that are 30 days or older), or you have insufficient permissions to delete matching environments."
)
result.set_unprocessable_entries(failed)
diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb
index 019246dfc9f..662980fe506 100644
--- a/app/services/event_create_service.rb
+++ b/app/services/event_create_service.rb
@@ -25,18 +25,22 @@ class EventCreateService
def open_mr(merge_request, current_user)
create_record_event(merge_request, current_user, :created).tap do
track_event(event_action: :created, event_target: MergeRequest, author_id: current_user.id)
- track_snowplow_event(merge_request, current_user,
- Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION,
- :create, 'merge_requests_users')
+ track_snowplow_event(
+ :created,
+ merge_request,
+ current_user
+ )
end
end
def close_mr(merge_request, current_user)
create_record_event(merge_request, current_user, :closed).tap do
track_event(event_action: :closed, event_target: MergeRequest, author_id: current_user.id)
- track_snowplow_event(merge_request, current_user,
- Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION,
- :close, 'merge_requests_users')
+ track_snowplow_event(
+ :closed,
+ merge_request,
+ current_user
+ )
end
end
@@ -47,9 +51,11 @@ class EventCreateService
def merge_mr(merge_request, current_user)
create_record_event(merge_request, current_user, :merged).tap do
track_event(event_action: :merged, event_target: MergeRequest, author_id: current_user.id)
- track_snowplow_event(merge_request, current_user,
- Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION,
- :merge, 'merge_requests_users')
+ track_snowplow_event(
+ :merged,
+ merge_request,
+ current_user
+ )
end
end
@@ -73,9 +79,12 @@ class EventCreateService
create_record_event(note, current_user, :commented).tap do
if note.is_a?(DiffNote) && note.for_merge_request?
track_event(event_action: :commented, event_target: MergeRequest, author_id: current_user.id)
- track_snowplow_event(note, current_user,
- Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION,
- :comment, 'merge_requests_users')
+ track_snowplow_event(
+ :commented,
+ note,
+ current_user
+ )
+
end
end
end
@@ -109,13 +118,13 @@ class EventCreateService
return [] if records.empty?
if create.any?
- track_snowplow_event(create.first, current_user,
+ old_track_snowplow_event(create.first, current_user,
Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION,
:create, 'design_users')
end
if update.any?
- track_snowplow_event(update.first, current_user,
+ old_track_snowplow_event(update.first, current_user,
Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION,
:update, 'design_users')
end
@@ -126,7 +135,7 @@ class EventCreateService
def destroy_designs(designs, current_user)
return [] unless designs.present?
- track_snowplow_event(designs.first, current_user,
+ old_track_snowplow_event(designs.first, current_user,
Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION,
:destroy, 'design_users')
create_record_events(designs.zip([:destroyed].cycle), current_user)
@@ -213,7 +222,15 @@ class EventCreateService
namespace = project.namespace
if Feature.enabled?(:route_hll_to_snowplow, namespace)
- Gitlab::Tracking.event(self.class.to_s, 'action_active_users_project_repo', namespace: namespace, user: current_user, project: project)
+ Gitlab::Tracking.event(
+ self.class.to_s,
+ :push,
+ label: 'usage_activity_by_stage_monthly.create.action_monthly_active_users_project_repo',
+ namespace: namespace,
+ user: current_user,
+ project: project,
+ context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: 'action_active_users_project_repo').to_context]
+ )
end
Users::LastPushEventService.new(current_user)
@@ -253,7 +270,10 @@ class EventCreateService
Gitlab::UsageDataCounters::TrackUniqueEvents.track_event(**params)
end
- def track_snowplow_event(record, current_user, category, action, label)
+ # This will be deleted as a part of
+ # https://gitlab.com/groups/gitlab-org/-/epics/8641
+ # once all the events are fixed
+ def old_track_snowplow_event(record, current_user, category, action, label)
return unless Feature.enabled?(:route_hll_to_snowplow_phase2)
project = record.project
@@ -266,6 +286,19 @@ class EventCreateService
user: current_user
)
end
+
+ def track_snowplow_event(action, record, user)
+ project = record.project
+ Gitlab::Tracking.event(
+ self.class.to_s,
+ action.to_s,
+ label: 'usage_activity_by_stage_monthly.create.merge_requests_users',
+ namespace: project.namespace,
+ user: user,
+ project: project,
+ context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: 'merge_requests_users').to_context]
+ )
+ end
end
EventCreateService.prepend_mod_with('EventCreateService')
diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb
index 269637805ad..7158116fde1 100644
--- a/app/services/git/base_hooks_service.rb
+++ b/app/services/git/base_hooks_service.rb
@@ -53,11 +53,11 @@ module Git
def create_pipelines
return unless params.fetch(:create_pipelines, true)
- Ci::CreatePipelineService
- .new(project, current_user, pipeline_params)
- .execute!(:push, pipeline_options)
- rescue Ci::CreatePipelineService::CreateError => ex
- log_pipeline_errors(ex)
+ response = Ci::CreatePipelineService
+ .new(project, current_user, pipeline_params)
+ .execute(:push, **pipeline_options)
+
+ log_pipeline_errors(response.message) unless response.payload.persisted?
end
def execute_project_hooks
@@ -148,14 +148,14 @@ module Git
{}
end
- def log_pipeline_errors(exception)
+ def log_pipeline_errors(error_message)
data = {
class: self.class.name,
correlation_id: Labkit::Correlation::CorrelationId.current_id.to_s,
project_id: project.id,
project_path: project.full_path,
message: "Error creating pipeline",
- errors: exception.to_s,
+ errors: error_message,
pipeline_params: sanitized_pipeline_params
}
diff --git a/app/services/google_cloud/create_service_accounts_service.rb b/app/services/google_cloud/create_service_accounts_service.rb
index 9617161b8e9..ca0aa7c91df 100644
--- a/app/services/google_cloud/create_service_accounts_service.rb
+++ b/app/services/google_cloud/create_service_accounts_service.rb
@@ -10,8 +10,8 @@ module GoogleCloud
service_accounts_service.add_for_project(
environment_name,
service_account.project_id,
- service_account.to_json,
- service_account_key.to_json,
+ Gitlab::Json.dump(service_account),
+ Gitlab::Json.dump(service_account_key),
ProtectedBranch.protected?(project, environment_name) || ProtectedTag.protected?(project, environment_name)
)
diff --git a/app/services/google_cloud/generate_pipeline_service.rb b/app/services/google_cloud/generate_pipeline_service.rb
index be0c7a783c9..b6438d6f501 100644
--- a/app/services/google_cloud/generate_pipeline_service.rb
+++ b/app/services/google_cloud/generate_pipeline_service.rb
@@ -34,7 +34,8 @@ module GoogleCloud
end
def generate_commit_attributes
- if action == ACTION_DEPLOY_TO_CLOUD_RUN
+ case action
+ when ACTION_DEPLOY_TO_CLOUD_RUN
branch_name = "deploy-to-cloud-run-#{SecureRandom.hex(8)}"
{
commit_message: 'Enable Cloud Run deployments',
@@ -43,7 +44,7 @@ module GoogleCloud
branch_name: branch_name,
start_branch: branch_name
}
- elsif action == ACTION_DEPLOY_TO_CLOUD_STORAGE
+ when ACTION_DEPLOY_TO_CLOUD_STORAGE
branch_name = "deploy-to-cloud-storage-#{SecureRandom.hex(8)}"
{
commit_message: 'Enable Cloud Storage deployments',
@@ -73,7 +74,7 @@ module GoogleCloud
includes << { 'remote' => include_url }
gitlab_ci_yml['include'] = includes.uniq
- gitlab_ci_yml.to_yaml
+ gitlab_ci_yml.deep_stringify_keys.to_yaml
end
end
end
diff --git a/app/services/google_cloud/setup_cloudsql_instance_service.rb b/app/services/google_cloud/setup_cloudsql_instance_service.rb
index 10237f83b37..40184b927ad 100644
--- a/app/services/google_cloud/setup_cloudsql_instance_service.rb
+++ b/app/services/google_cloud/setup_cloudsql_instance_service.rb
@@ -13,7 +13,7 @@ module GoogleCloud
get_instance_response = google_api_client.get_cloudsql_instance(gcp_project_id, instance_name)
if get_instance_response.state != INSTANCE_STATE_RUNNABLE
- return error("CloudSQL instance not RUNNABLE: #{get_instance_response.to_json}")
+ return error("CloudSQL instance not RUNNABLE: #{Gitlab::Json.dump(get_instance_response)}")
end
save_instance_ci_vars(get_instance_response)
@@ -42,7 +42,7 @@ module GoogleCloud
success
rescue Google::Apis::Error => err
- error(message: err.to_json)
+ error(message: Gitlab::Json.dump(err))
end
private
@@ -97,7 +97,7 @@ module GoogleCloud
database_response = google_api_client.create_cloudsql_database(gcp_project_id, instance_name, database_name)
if database_response.status != OPERATION_STATE_DONE
- return error("Database creation failed: #{database_response.to_json}")
+ return error("Database creation failed: #{Gitlab::Json.dump(database_response)}")
end
success
@@ -109,7 +109,7 @@ module GoogleCloud
user_response = google_api_client.create_cloudsql_user(gcp_project_id, instance_name, username, password)
if user_response.status != OPERATION_STATE_DONE
- return error("User creation failed: #{user_response.to_json}")
+ return error("User creation failed: #{Gitlab::Json.dump(user_response)}")
end
success
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index d508865ef32..68bb6427350 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -57,7 +57,7 @@ module Groups
end
def after_create_hook
- track_experiment_event
+ # overridden in EE
end
def remove_unallowed_params
@@ -109,15 +109,6 @@ module Groups
@group.shared_runners_enabled = @group.parent.shared_runners_enabled
@group.allow_descendants_override_disabled_shared_runners = @group.parent.allow_descendants_override_disabled_shared_runners
end
-
- def track_experiment_event
- return unless group.persisted?
-
- # Track namespace created events to relate them with signed up events for
- # the same experiment. This will let us associate created namespaces to
- # users that signed up from the experimental logged out header.
- experiment(:logged_out_marketing_header, actor: current_user).track(:namespace_created, namespace: group)
- end
end
end
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index 2135892a95a..925a2acbb58 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -10,6 +10,8 @@ module Groups
reject_parent_id!
remove_unallowed_params
+ before_assignment_hook(group, params)
+
if renaming_group_with_container_registry_images?
group.errors.add(:base, container_images_error)
return false
@@ -25,8 +27,6 @@ module Groups
handle_changes
- before_assignment_hook(group, params)
-
handle_namespace_settings
group.assign_attributes(params)
diff --git a/app/services/incident_management/timeline_event_tags/base_service.rb b/app/services/incident_management/timeline_event_tags/base_service.rb
new file mode 100644
index 00000000000..7bb596dcd92
--- /dev/null
+++ b/app/services/incident_management/timeline_event_tags/base_service.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ module TimelineEventTags
+ class BaseService
+ def allowed?
+ user&.can?(:admin_incident_management_timeline_event_tag, project)
+ end
+
+ def success(timeline_event_tag)
+ ServiceResponse.success(payload: { timeline_event_tag: timeline_event_tag })
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def error_no_permissions
+ error(_('You have insufficient permissions to manage timeline event tags for this project'))
+ end
+
+ def error_in_save(timeline_event_tag)
+ error(timeline_event_tag.errors.full_messages.to_sentence)
+ end
+ end
+ end
+end
diff --git a/app/services/incident_management/timeline_event_tags/create_service.rb b/app/services/incident_management/timeline_event_tags/create_service.rb
new file mode 100644
index 00000000000..6742bb6ba5c
--- /dev/null
+++ b/app/services/incident_management/timeline_event_tags/create_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ module TimelineEventTags
+ class CreateService < TimelineEventTags::BaseService
+ attr_reader :project, :user, :params
+
+ def initialize(project, user, params)
+ @project = project
+ @user = user
+ @params = params
+ end
+
+ def execute
+ return error_no_permissions unless allowed?
+
+ timeline_event_tag_params = {
+ project: project,
+ name: params[:name]
+ }
+
+ timeline_event_tag = IncidentManagement::TimelineEventTag.new(timeline_event_tag_params)
+
+ if timeline_event_tag.save
+ success(timeline_event_tag)
+ else
+ error_in_save(timeline_event_tag)
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/incident_management/timeline_events/create_service.rb b/app/services/incident_management/timeline_events/create_service.rb
index 5422b4ad6d2..71ff5b64515 100644
--- a/app/services/incident_management/timeline_events/create_service.rb
+++ b/app/services/incident_management/timeline_events/create_service.rb
@@ -5,6 +5,7 @@ module IncidentManagement
DEFAULT_ACTION = 'comment'
DEFAULT_EDITABLE = false
DEFAULT_AUTO_CREATED = false
+ AUTOCREATE_TAGS = [TimelineEventTag::START_TIME_TAG_NAME, TimelineEventTag::END_TIME_TAG_NAME].freeze
class CreateService < TimelineEvents::BaseService
def initialize(incident, user, params)
@@ -49,6 +50,15 @@ module IncidentManagement
new(incident, user, note: note, occurred_at: occurred_at, action: action, auto_created: true).execute
end
+ def change_severity(incident, user)
+ severity_label = IssuableSeverity::SEVERITY_LABELS[incident.severity.to_sym]
+ note = "@#{user.username} changed the incident severity to **#{severity_label}**"
+ occurred_at = incident.updated_at
+ action = 'severity'
+
+ new(incident, user, note: note, occurred_at: occurred_at, action: action, auto_created: true).execute
+ end
+
def change_labels(incident, user, added_labels: [], removed_labels: [])
return if Feature.disabled?(:incident_timeline_events_from_labels, incident.project)
@@ -85,10 +95,17 @@ module IncidentManagement
editable: params.fetch(:editable, DEFAULT_EDITABLE)
}
+ non_existing_tags = validate_tags(project, params[:timeline_event_tag_names])
+
+ return error("#{_("Following tags don't exist")}: #{non_existing_tags}") unless non_existing_tags.empty?
+
timeline_event = IncidentManagement::TimelineEvent.new(timeline_event_params)
- if timeline_event.save
+ if timeline_event.save(context: validation_context)
add_system_note(timeline_event)
+
+ create_timeline_event_tag_links(timeline_event, params[:timeline_event_tag_names])
+
track_usage_event(:incident_management_timeline_event_created, user.id)
success(timeline_event)
@@ -112,6 +129,53 @@ module IncidentManagement
SystemNoteService.add_timeline_event(timeline_event)
end
+
+ def validation_context
+ :user_input if !auto_created && params[:promoted_from_note].blank?
+ end
+
+ def create_timeline_event_tag_links(timeline_event, tag_names)
+ return unless tag_names&.any?
+
+ auto_create_predefined_tags(tag_names)
+
+ # Refetches the tag objects to consider predefined tags as well
+ tags = project.incident_management_timeline_event_tags.by_names(tag_names)
+
+ tag_links = tags.select(:id).map do |tag|
+ {
+ timeline_event_id: timeline_event.id,
+ timeline_event_tag_id: tag.id,
+ created_at: DateTime.current
+ }
+ end
+
+ IncidentManagement::TimelineEventTagLink.insert_all(tag_links) if tag_links.any?
+ end
+
+ def auto_create_predefined_tags(new_tags)
+ new_tags = new_tags.map(&:downcase)
+
+ tags_to_create = AUTOCREATE_TAGS.select { |tag| tag.downcase.in?(new_tags) }
+
+ tags_to_create.each do |name|
+ project.incident_management_timeline_event_tags.create(name: name)
+ end
+ end
+
+ def validate_tags(project, tag_names)
+ return [] unless tag_names&.any?
+
+ start_time_tag = AUTOCREATE_TAGS[0].downcase
+ end_time_tag = AUTOCREATE_TAGS[1].downcase
+
+ tag_names_downcased = tag_names.map(&:downcase)
+
+ tags = project.incident_management_timeline_event_tags.by_names(tag_names).pluck_names.map(&:downcase)
+
+ # remove tags from given tag_names and also remove predefined tags which can be auto created
+ tag_names_downcased - tags - [start_time_tag, end_time_tag]
+ end
end
end
end
diff --git a/app/services/incident_management/timeline_events/update_service.rb b/app/services/incident_management/timeline_events/update_service.rb
index 012e2f0e260..8d4e29c6857 100644
--- a/app/services/incident_management/timeline_events/update_service.rb
+++ b/app/services/incident_management/timeline_events/update_service.rb
@@ -8,18 +8,23 @@ module IncidentManagement
# @option params [string] note
# @option params [datetime] occurred_at
class UpdateService < TimelineEvents::BaseService
+ VALIDATION_CONTEXT = :user_input
+
def initialize(timeline_event, user, params)
@timeline_event = timeline_event
@incident = timeline_event.incident
@user = user
@note = params[:note]
@occurred_at = params[:occurred_at]
+ @validation_context = VALIDATION_CONTEXT
end
def execute
return error_no_permissions unless allowed?
- if timeline_event.update(update_params)
+ timeline_event.assign_attributes(update_params)
+
+ if timeline_event.save(context: validation_context)
add_system_note(timeline_event)
track_usage_event(:incident_management_timeline_event_edited, user.id)
@@ -31,7 +36,7 @@ module IncidentManagement
private
- attr_reader :timeline_event, :incident, :user, :note, :occurred_at
+ attr_reader :timeline_event, :incident, :user, :note, :occurred_at, :validation_context
def update_params
{ updated_by_user: user, note: note, occurred_at: occurred_at }.compact
diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb
index 238f5ebddae..30444fa3938 100644
--- a/app/services/issuable/bulk_update_service.rb
+++ b/app/services/issuable/bulk_update_service.rb
@@ -68,9 +68,10 @@ module Issuable
end
def find_issuables(parent, model_class, ids)
- if parent.is_a?(Project)
+ case parent
+ when Project
projects = parent
- elsif parent.is_a?(Group)
+ when Group
projects = parent.all_projects
else
return
diff --git a/app/services/issuable/discussions_list_service.rb b/app/services/issuable/discussions_list_service.rb
new file mode 100644
index 00000000000..7aa0363af01
--- /dev/null
+++ b/app/services/issuable/discussions_list_service.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+# This service return notes grouped by discussion ID and paginated per discussion.
+# System notes also have a discussion ID assigned including Synthetic system notes.
+module Issuable
+ class DiscussionsListService
+ include RendersNotes
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :current_user, :issuable, :params
+
+ def initialize(current_user, issuable, params = {})
+ @current_user = current_user
+ @issuable = issuable
+ @params = params.dup
+ end
+
+ def execute
+ return Note.none unless can_read_issuable?
+
+ notes = NotesFinder.new(current_user, params.merge({ target: issuable, project: issuable.project }))
+ .execute.with_web_entity_associations.inc_relations_for_view.fresh
+
+ if paginator
+ paginated_discussions_by_type = paginator.records.group_by(&:table_name)
+
+ notes = if paginated_discussions_by_type['notes'].present?
+ notes.with_discussion_ids(paginated_discussions_by_type['notes'].map(&:discussion_id))
+ else
+ notes.none
+ end
+ end
+
+ if params[:notes_filter] != UserPreference::NOTES_FILTERS[:only_comments]
+ notes = ResourceEvents::MergeIntoNotesService.new(
+ issuable, current_user, paginated_notes: paginated_discussions_by_type
+ ).execute(notes)
+ end
+
+ notes = prepare_notes_for_rendering(notes)
+
+ # TODO: optimize this permission check.
+ # Given this loads notes on a single issuable and current permission system, we should not have to check
+ # permission on every single note. We should be able to check permission on the given issuable or its container,
+ # which should result in just one permission check. Perhaps that should also either be passed to NotesFinder or
+ # should be done in NotesFinder, which would decide right away if it would need to return no notes
+ # or if it should just filter out internal notes.
+ notes = notes.select { |n| n.readable_by?(current_user) }
+
+ Discussion.build_collection(notes, issuable)
+ end
+
+ def paginator
+ return if params[:per_page].blank?
+ return if issuable.instance_of?(MergeRequest) && Feature.disabled?(:paginated_mr_discussions, issuable.project)
+
+ strong_memoize(:paginator) do
+ issuable
+ .discussion_root_note_ids(notes_filter: params[:notes_filter])
+ .keyset_paginate(cursor: params[:cursor], per_page: params[:per_page].to_i)
+ end
+ end
+
+ def can_read_issuable?
+ return Ability.allowed?(current_user, :read_security_resource, issuable) if issuable.is_a?(Vulnerability)
+
+ Ability.allowed?(current_user, :"read_#{issuable.to_ability_name}", issuable)
+ end
+ end
+end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index e5feb4422f6..0aed9e3ba40 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -163,6 +163,7 @@ module Issues
invalidate_milestone_issue_counters(issue)
send_milestone_change_notification(issue)
+ GraphqlTriggers.issuable_milestone_updated(issue)
end
def invalidate_milestone_issue_counters(issue)
diff --git a/app/services/jira_import/start_import_service.rb b/app/services/jira_import/start_import_service.rb
index 4d1f2c94ac8..9cd56cf339e 100644
--- a/app/services/jira_import/start_import_service.rb
+++ b/app/services/jira_import/start_import_service.rb
@@ -40,7 +40,7 @@ module JiraImport
project.import_type = 'jira'
project.save! && jira_import.schedule!
- ServiceResponse.success(payload: { import_data: jira_import } )
+ ServiceResponse.success(payload: { import_data: jira_import })
rescue StandardError => ex
# in case project.save! raises an error
Gitlab::ErrorTracking.track_exception(ex, project_id: project.id)
diff --git a/app/services/labels/transfer_service.rb b/app/services/labels/transfer_service.rb
index a79e5b00232..79e807d8010 100644
--- a/app/services/labels/transfer_service.rb
+++ b/app/services/labels/transfer_service.rb
@@ -51,7 +51,7 @@ module Labels
# rubocop: disable CodeReuse/ActiveRecord
def group_labels_applied_to_issues
@labels_applied_to_issues ||= Label.joins(:issues)
- .joins("INNER JOIN namespaces on namespaces.id = labels.group_id AND namespaces.type = 'Group'" )
+ .joins("INNER JOIN namespaces on namespaces.id = labels.group_id AND namespaces.type = 'Group'")
.where(issues: { project_id: project.id }).reorder(nil)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -59,7 +59,7 @@ module Labels
# rubocop: disable CodeReuse/ActiveRecord
def group_labels_applied_to_merge_requests
@labels_applied_to_mrs ||= Label.joins(:merge_requests)
- .joins("INNER JOIN namespaces on namespaces.id = labels.group_id AND namespaces.type = 'Group'" )
+ .joins("INNER JOIN namespaces on namespaces.id = labels.group_id AND namespaces.type = 'Group'")
.where(merge_requests: { target_project_id: project.id }).reorder(nil)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/loose_foreign_keys/process_deleted_records_service.rb b/app/services/loose_foreign_keys/process_deleted_records_service.rb
index 54f54d99afb..8700276c982 100644
--- a/app/services/loose_foreign_keys/process_deleted_records_service.rb
+++ b/app/services/loose_foreign_keys/process_deleted_records_service.rb
@@ -9,6 +9,7 @@ module LooseForeignKeys
end
def execute
+ raised_error = false
modification_tracker = ModificationTracker.new
tracked_tables.cycle do |table|
records = load_batch_for_table(table)
@@ -35,13 +36,30 @@ module LooseForeignKeys
break if modification_tracker.over_limit?
end
+ ::Gitlab::Metrics::LooseForeignKeysSlis.record_apdex(
+ success: !modification_tracker.over_limit?,
+ db_config_name: db_config_name
+ )
+
modification_tracker.stats
+ rescue StandardError
+ raised_error = true
+ raise
+ ensure
+ ::Gitlab::Metrics::LooseForeignKeysSlis.record_error_rate(
+ error: raised_error,
+ db_config_name: db_config_name
+ )
end
private
attr_reader :connection
+ def db_config_name
+ ::Gitlab::Database.db_config_name(connection)
+ end
+
def load_batch_for_table(table)
fully_qualified_table_name = "#{current_schema}.#{table}"
LooseForeignKeys::DeletedRecord.load_batch_for_table(fully_qualified_table_name, BATCH_SIZE)
diff --git a/app/services/markup/rendering_service.rb b/app/services/markup/rendering_service.rb
new file mode 100644
index 00000000000..0142d600522
--- /dev/null
+++ b/app/services/markup/rendering_service.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+module Markup
+ class RenderingService
+ include ActionView::Helpers::TextHelper
+
+ # Let's increase the render timeout
+ # For a smaller one, a test that renders the blob content statically fails
+ # We can consider removing this custom timeout when markup_rendering_timeout FF is removed:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/365358
+ RENDER_TIMEOUT = 5.seconds
+
+ def initialize(text, file_name: nil, context: {}, postprocess_context: {})
+ @text = text
+ @file_name = file_name
+ @context = context
+ @postprocess_context = postprocess_context
+ end
+
+ def execute
+ return '' unless text.present?
+ return context.delete(:rendered) if context.has_key?(:rendered)
+
+ html = file_name ? markup_unsafe : markdown_unsafe
+
+ return '' unless html.present?
+
+ postprocess_context ? postprocess(html) : html
+ end
+
+ private
+
+ def markup_unsafe
+ markup = proc do
+ if Gitlab::MarkupHelper.gitlab_markdown?(file_name)
+ markdown_unsafe
+ elsif Gitlab::MarkupHelper.asciidoc?(file_name)
+ asciidoc_unsafe
+ elsif Gitlab::MarkupHelper.plain?(file_name)
+ plain_unsafe
+ else
+ other_markup_unsafe
+ end
+ end
+
+ if Feature.enabled?(:markup_rendering_timeout, context[:project])
+ Gitlab::RenderTimeout.timeout(foreground: RENDER_TIMEOUT, &markup)
+ else
+ markup.call
+ end
+ rescue StandardError => e
+ Gitlab::ErrorTracking.track_exception(e, project_id: context[:project]&.id, file_name: file_name)
+
+ simple_format(text)
+ end
+
+ def markdown_unsafe
+ Banzai.render(text, context)
+ end
+
+ def asciidoc_unsafe
+ Gitlab::Asciidoc.render(text, context)
+ end
+
+ def plain_unsafe
+ "<pre class=\"plain-readme\">#{text}</pre>"
+ end
+
+ def other_markup_unsafe
+ Gitlab::OtherMarkup.render(file_name, text, context)
+ end
+
+ def postprocess(html)
+ Banzai.post_process(html, context.reverse_merge(postprocess_context))
+ end
+
+ attr_reader :text, :file_name, :context, :postprocess_context
+ end
+end
diff --git a/app/services/members/approve_access_request_service.rb b/app/services/members/approve_access_request_service.rb
index 5337279f702..51f9492ec91 100644
--- a/app/services/members/approve_access_request_service.rb
+++ b/app/services/members/approve_access_request_service.rb
@@ -16,7 +16,7 @@ module Members
private
def validate_access!(access_requester)
- raise Gitlab::Access::AccessDeniedError unless can_update_access_requester?(access_requester)
+ raise Gitlab::Access::AccessDeniedError unless can_approve_access_requester?(access_requester)
if approving_member_with_owner_access_level?(access_requester) &&
cannot_assign_owner_responsibilities_to_member_in_project?(access_requester)
@@ -24,8 +24,8 @@ module Members
end
end
- def can_update_access_requester?(access_requester)
- can?(current_user, update_member_permission(access_requester), access_requester)
+ def can_approve_access_requester?(access_requester)
+ can?(current_user, :admin_member_access_request, access_requester.source)
end
def approving_member_with_owner_access_level?(access_requester)
diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb
index ce79907e8a8..f18269454e3 100644
--- a/app/services/members/destroy_service.rb
+++ b/app/services/members/destroy_service.rb
@@ -48,6 +48,10 @@ module Members
def authorized?(member, destroy_bot)
return can_destroy_bot_member?(member) if destroy_bot
+ if member.request?
+ return can_destroy_member_access_request?(member) || can_withdraw_member_access_request?(member)
+ end
+
can_destroy_member?(member)
end
@@ -106,6 +110,14 @@ module Members
can?(current_user, destroy_bot_member_permission(member), member)
end
+ def can_destroy_member_access_request?(member)
+ can?(current_user, :admin_member_access_request, member.source)
+ end
+
+ def can_withdraw_member_access_request?(member)
+ can?(current_user, :withdraw_member_access_request, member)
+ end
+
def destroying_member_with_owner_access_level?(member)
member.owner?
end
diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb
index 8ef3e307519..0e6b02f7a80 100644
--- a/app/services/members/update_service.rb
+++ b/app/services/members/update_service.rb
@@ -2,37 +2,84 @@
module Members
class UpdateService < Members::BaseService
- # returns the updated member
- def execute(member, permission: :update)
- raise Gitlab::Access::AccessDeniedError unless can?(current_user, action_member_permission(permission, member), member)
- raise Gitlab::Access::AccessDeniedError if prevent_upgrade_to_owner?(member) || prevent_downgrade_from_owner?(member)
+ # @param members [Member, Array<Member>]
+ # returns the updated member(s)
+ def execute(members, permission: :update)
+ members = Array.wrap(members)
- return success(member: member) if update_results_in_no_change?(member)
-
- old_access_level = member.human_access
- old_expiry = member.expires_at
-
- if member.update(params)
- after_execute(action: permission, old_access_level: old_access_level, old_expiry: old_expiry, member: member)
-
- # Deletes only confidential issues todos for guests
- enqueue_delete_todos(member) if downgrading_to_guest?
+ old_access_level_expiry_map = members.to_h do |member|
+ [member.id, { human_access: member.human_access, expires_at: member.expires_at }]
end
- if member.errors.any?
- error(member.errors.full_messages.to_sentence, pass_back: { member: member })
+ if Feature.enabled?(:bulk_update_membership_roles, current_user)
+ multiple_members_update(members, permission, old_access_level_expiry_map)
else
- success(member: member)
+ single_member_update(members.first, permission, old_access_level_expiry_map)
end
+
+ prepare_response(members)
end
private
- def update_results_in_no_change?(member)
- return false if params[:expires_at]&.to_date != member.expires_at
- return false if params[:access_level] != member.access_level
+ def single_member_update(member, permission, old_access_level_expiry_map)
+ raise Gitlab::Access::AccessDeniedError unless has_update_permissions?(member, permission)
+
+ member.attributes = params
+ return success(member: member) unless member.changed?
+
+ post_update(member, permission, old_access_level_expiry_map) if member.save
+ end
+
+ def multiple_members_update(members, permission, old_access_level_expiry_map)
+ begin
+ updated_members =
+ Member.transaction do
+ # Using `next` with `filter_map` avoids the `post_update` call for the member that resulted in no change
+ members.filter_map do |member|
+ raise Gitlab::Access::AccessDeniedError unless has_update_permissions?(member, permission)
+
+ member.attributes = params
+ next unless member.changed?
+
+ member.save!
+ member
+ end
+ end
+ rescue ActiveRecord::RecordInvalid
+ return
+ end
+
+ updated_members.each { |member| post_update(member, permission, old_access_level_expiry_map) }
+ end
+
+ def post_update(member, permission, old_access_level_expiry_map)
+ old_access_level = old_access_level_expiry_map[member.id][:human_access]
+ old_expiry = old_access_level_expiry_map[member.id][:expires_at]
+
+ after_execute(action: permission, old_access_level: old_access_level, old_expiry: old_expiry, member: member)
+ enqueue_delete_todos(member) if downgrading_to_guest? # Deletes only confidential issues todos for guests
+ end
+
+ def prepare_response(members)
+ errored_member = members.detect { |member| member.errors.any? }
+ if errored_member.present?
+ return error(errored_member.errors.full_messages.to_sentence, pass_back: { member: errored_member })
+ end
+
+ # TODO: Remove the :member key when removing the bulk_update_membership_roles FF and update where it's used.
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/373257
+ if members.one?
+ success(member: members.first)
+ else
+ success(members: members)
+ end
+ end
- true
+ def has_update_permissions?(member, permission)
+ can?(current_user, action_member_permission(permission, member), member) &&
+ !prevent_upgrade_to_owner?(member) &&
+ !prevent_downgrade_from_owner?(member)
end
def downgrading_to_guest?
diff --git a/app/services/merge_requests/add_context_service.rb b/app/services/merge_requests/add_context_service.rb
index 7b441ddf5e4..2ce6073050e 100644
--- a/app/services/merge_requests/add_context_service.rb
+++ b/app/services/merge_requests/add_context_service.rb
@@ -65,7 +65,7 @@ module MergeRequests
sha: sha,
authored_date: Gitlab::Database.sanitize_timestamp(commit_hash[:authored_date]),
committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date]),
- trailers: commit_hash.fetch(:trailers, {}).to_json
+ trailers: Gitlab::Json.dump(commit_hash.fetch(:trailers, {}))
)
end
end
diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb
index 9d12eb80eb6..20b32dbc2a0 100644
--- a/app/services/merge_requests/after_create_service.rb
+++ b/app/services/merge_requests/after_create_service.rb
@@ -5,6 +5,8 @@ module MergeRequests
include Gitlab::Utils::StrongMemoize
def execute(merge_request)
+ merge_request.ensure_merge_request_diff
+
prepare_for_mergeability(merge_request)
prepare_merge_request(merge_request)
end
diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb
index 5761e34caff..72f398ce415 100644
--- a/app/services/merge_requests/approval_service.rb
+++ b/app/services/merge_requests/approval_service.rb
@@ -11,6 +11,8 @@ module MergeRequests
reset_approvals_cache(merge_request)
merge_request_activity_counter.track_approve_mr_action(user: current_user, merge_request: merge_request)
+ trigger_merge_request_merge_status_updated(merge_request)
+ trigger_merge_request_reviewers_updated(merge_request)
# Approval side effects (things not required to be done immediately but
# should happen after a successful approval) should be done asynchronously
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index cfd7c645b7e..e7ab2c062ee 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -20,7 +20,7 @@ module MergeRequests
end
def execute_hooks(merge_request, action = 'open', old_rev: nil, old_associations: {})
- merge_data = hook_data(merge_request, action, old_rev: old_rev, old_associations: old_associations)
+ 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)
@@ -249,6 +249,10 @@ module MergeRequests
def trigger_merge_request_reviewers_updated(merge_request)
GraphqlTriggers.merge_request_reviewers_updated(merge_request)
end
+
+ def trigger_merge_request_merge_status_updated(merge_request)
+ GraphqlTriggers.merge_request_merge_status_updated(merge_request)
+ end
end
end
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index 8e0f72eb380..04d08f257f1 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -14,11 +14,15 @@ module MergeRequests
end
def after_create(issuable)
- issuable.mark_as_preparing
+ current_user_id = current_user.id
+
+ issuable.run_after_commit do
+ # Add new items to MergeRequests::AfterCreateService if they can
+ # be performed in Sidekiq
+ NewMergeRequestWorker.perform_async(issuable.id, current_user_id)
+ end
- # Add new items to MergeRequests::AfterCreateService if they can
- # be performed in Sidekiq
- NewMergeRequestWorker.perform_async(issuable.id, current_user.id)
+ issuable.mark_as_preparing
super
end
@@ -34,7 +38,12 @@ module MergeRequests
# callback (e.g. after_create), a database transaction will be
# open while the Gitaly RPC waits. To avoid an idle in transaction
# timeout, we do this before we attempt to save the merge request.
- merge_request.eager_fetch_ref!
+
+ if Feature.enabled?(:async_merge_request_diff_creation, current_user)
+ merge_request.skip_ensure_merge_request_diff = true
+ else
+ merge_request.eager_fetch_ref!
+ end
end
def set_projects!
@@ -59,4 +68,4 @@ module MergeRequests
end
end
-MergeRequests::CreateService.include_mod_with('MergeRequests::CreateService')
+MergeRequests::CreateService.prepend_mod_with('MergeRequests::CreateService')
diff --git a/app/services/merge_requests/mergeability/run_checks_service.rb b/app/services/merge_requests/mergeability/run_checks_service.rb
index 7f205c8dd6c..740a6feac2c 100644
--- a/app/services/merge_requests/mergeability/run_checks_service.rb
+++ b/app/services/merge_requests/mergeability/run_checks_service.rb
@@ -38,7 +38,7 @@ module MergeRequests
def failure_reason
raise 'Execute needs to be called before' if results.nil?
- results.find(&:failed?)&.payload&.fetch(:reason)
+ results.find(&:failed?)&.payload&.fetch(:reason)&.to_sym
end
private
@@ -46,7 +46,6 @@ module MergeRequests
attr_reader :merge_request, :params, :results
def run_check(check)
- return check.execute unless Feature.enabled?(:mergeability_caching, merge_request.project)
return check.execute unless check.cacheable?
cached_result = cached_results.read(merge_check: check)
diff --git a/app/services/merge_requests/mergeability_check_service.rb b/app/services/merge_requests/mergeability_check_service.rb
index 1ce44f465cd..2a3f417a33b 100644
--- a/app/services/merge_requests/mergeability_check_service.rb
+++ b/app/services/merge_requests/mergeability_check_service.rb
@@ -156,8 +156,7 @@ module MergeRequests
end
def merge_to_ref
- params = { allow_conflicts: Feature.enabled?(:display_merge_conflicts_in_diff, project) }
- result = MergeRequests::MergeToRefService.new(project: project, current_user: merge_request.author, params: params).execute(merge_request)
+ result = MergeRequests::MergeToRefService.new(project: project, current_user: merge_request.author, params: {}).execute(merge_request)
result[:status] == :success
end
diff --git a/app/services/merge_requests/remove_approval_service.rb b/app/services/merge_requests/remove_approval_service.rb
index 52628729519..8387c23fe3f 100644
--- a/app/services/merge_requests/remove_approval_service.rb
+++ b/app/services/merge_requests/remove_approval_service.rb
@@ -17,6 +17,8 @@ module MergeRequests
reset_approvals_cache(merge_request)
create_note(merge_request)
merge_request_activity_counter.track_unapprove_mr_action(user: current_user)
+ trigger_merge_request_merge_status_updated(merge_request)
+ trigger_merge_request_reviewers_updated(merge_request)
end
success
diff --git a/app/services/merge_requests/update_assignees_service.rb b/app/services/merge_requests/update_assignees_service.rb
index 79a3e9f3c22..d45d55cbebc 100644
--- a/app/services/merge_requests/update_assignees_service.rb
+++ b/app/services/merge_requests/update_assignees_service.rb
@@ -19,16 +19,9 @@ module MergeRequests
attrs = update_attrs.merge(assignee_ids: new_ids)
- # We now have assignees validation on merge request
- # If we use an update with bang, it will explode,
- # instead we need to check if its valid then return if its not valid.
- if Feature.enabled?(:limit_assignees_per_issuable)
- merge_request.update(**attrs)
-
- return merge_request unless merge_request.valid?
- else
- merge_request.update!(**attrs)
- end
+ merge_request.update(**attrs)
+
+ return merge_request unless merge_request.valid?
# Defer the more expensive operations (handle_assignee_changes) to the background
MergeRequests::HandleAssigneesChangeService
diff --git a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb
index 0651e569d07..62264281a02 100644
--- a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb
+++ b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Fetches the self monitoring metrics dashboard and formats the output.
+# Fetches the self-monitoring metrics dashboard and formats the output.
# Use Gitlab::Metrics::Dashboard::Finder to retrieve dashboards.
module Metrics
module Dashboard
diff --git a/app/services/milestones/transfer_service.rb b/app/services/milestones/transfer_service.rb
index bbf6920f83b..fa6b461b75d 100644
--- a/app/services/milestones/transfer_service.rb
+++ b/app/services/milestones/transfer_service.rb
@@ -62,7 +62,7 @@ module Milestones
# rubocop: enable CodeReuse/ActiveRecord
def find_or_create_milestone(milestone)
- params = milestone.attributes.slice('title', 'description', 'start_date', 'due_date')
+ params = milestone.attributes.slice('title', 'description', 'start_date', 'due_date', 'state')
FindOrCreateService.new(project, current_user, params).execute
end
diff --git a/app/services/namespaces/statistics_refresher_service.rb b/app/services/namespaces/statistics_refresher_service.rb
index 805060cdee9..2580d2f09fd 100644
--- a/app/services/namespaces/statistics_refresher_service.rb
+++ b/app/services/namespaces/statistics_refresher_service.rb
@@ -5,6 +5,7 @@ module Namespaces
RefresherError = Class.new(StandardError)
def execute(root_namespace)
+ root_namespace = root_namespace.root_ancestor # just in case the true root isn't passed
root_storage_statistics = find_or_create_root_storage_statistics(root_namespace.id)
root_storage_statistics.recalculate!
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 1aaf7fb769a..555d60dc291 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -137,8 +137,6 @@ module Notes
end
def invalid_assignees?(update_params)
- return false unless Feature.enabled?(:limit_assignees_per_issuable)
-
if update_params.key?(:assignee_ids)
possible_assignees = update_params[:assignee_ids]&.uniq&.size
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 1224cf80b76..660d9891e46 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -16,6 +16,16 @@
# NotificationService.new.async.new_issue(issue, current_user)
#
class NotificationService
+ # These should not be called by the MailScheduler::NotificationServiceWorker -
+ # what would it even mean?
+ EXCLUDED_ACTIONS = %i[async].freeze
+
+ def self.permitted_actions
+ @permitted_actions ||= gitlab_extensions.flat_map do |klass|
+ klass.public_instance_methods(false) - EXCLUDED_ACTIONS
+ end.to_set
+ end
+
class Async
attr_reader :parent
diff --git a/app/services/packages/debian/create_distribution_service.rb b/app/services/packages/debian/create_distribution_service.rb
index b4b1ec952ef..218423bb8e3 100644
--- a/app/services/packages/debian/create_distribution_service.rb
+++ b/app/services/packages/debian/create_distribution_service.rb
@@ -61,7 +61,7 @@ module Packages
create_objects(distribution.architectures, architectures, error_label: 'Architecture')
end
- def create_objects(objects, object_names_from_params, error_label: )
+ def create_objects(objects, object_names_from_params, error_label:)
object_names_from_params.each do |name|
new_object = objects.create(name: name)
append_errors(new_object, error_label)
diff --git a/app/services/packages/debian/update_distribution_service.rb b/app/services/packages/debian/update_distribution_service.rb
index 218167ecdc5..5096bd5361f 100644
--- a/app/services/packages/debian/update_distribution_service.rb
+++ b/app/services/packages/debian/update_distribution_service.rb
@@ -58,7 +58,7 @@ module Packages
update_objects(distribution.architectures, architectures, error_label: 'Architecture')
end
- def update_objects(objects, object_names_from_params, error_label: )
+ def update_objects(objects, object_names_from_params, error_label:)
current_object_names = objects.map(&:name)
missing_object_names = object_names_from_params - current_object_names
extra_object_names = current_object_names - object_names_from_params
diff --git a/app/services/packages/maven/metadata/base_create_xml_service.rb b/app/services/packages/maven/metadata/base_create_xml_service.rb
index 4d5cab4978e..3b0d93e1dfb 100644
--- a/app/services/packages/maven/metadata/base_create_xml_service.rb
+++ b/app/services/packages/maven/metadata/base_create_xml_service.rb
@@ -8,13 +8,16 @@ module Packages
INDENT_SPACE = 2
- def initialize(metadata_content:, package:)
+ def initialize(metadata_content:, package:, logger: nil)
@metadata_content = metadata_content
@package = package
+ @logger = logger || Gitlab::AppJsonLogger
end
private
+ attr_reader :logger
+
def xml_doc
strong_memoize(:xml_doc) do
Nokogiri::XML(@metadata_content) do |config|
diff --git a/app/services/packages/maven/metadata/create_versions_xml_service.rb b/app/services/packages/maven/metadata/create_versions_xml_service.rb
index 13b6efa8650..c2ac7fea703 100644
--- a/app/services/packages/maven/metadata/create_versions_xml_service.rb
+++ b/app/services/packages/maven/metadata/create_versions_xml_service.rb
@@ -67,6 +67,12 @@ module Packages
def update_release
return false if release_coherent?
+ unless release_xml_node.present?
+ log_malformed_content('Missing release tag')
+
+ return false
+ end
+
if release_from_database
release_xml_node.content = release_from_database
else
@@ -159,6 +165,15 @@ module Packages
non_snapshot_versions_from_database.last
end
end
+
+ def log_malformed_content(reason)
+ logger.warn(
+ message: 'A malformed metadata file has been encountered',
+ reason: reason,
+ project_id: @package.project_id,
+ package_id: @package.id
+ )
+ 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 a3596314199..dd074f7472b 100644
--- a/app/services/packages/npm/create_package_service.rb
+++ b/app/services/packages/npm/create_package_service.rb
@@ -81,7 +81,7 @@ module Packages
# - https://blog.aaronlenoir.com/2017/11/10/get-original-length-from-base-64-string/
# - https://en.wikipedia.org/wiki/Base64#Decoding_Base64_with_padding
encoded_data = attachment['data']
- ((encoded_data.length * 0.75 ) - encoded_data[-2..].count('=')).to_i
+ ((encoded_data.length * 0.75) - encoded_data[-2..].count('=')).to_i
end
end
diff --git a/app/services/packages/rpm/parse_package_service.rb b/app/services/packages/rpm/parse_package_service.rb
index 689a161a81a..18b916a9d8b 100644
--- a/app/services/packages/rpm/parse_package_service.rb
+++ b/app/services/packages/rpm/parse_package_service.rb
@@ -25,7 +25,8 @@ module Packages
epoch: package_tags[:epoch] || '0',
changelogs: build_changelogs,
requirements: build_requirements,
- provides: build_provides
+ provides: build_provides,
+ directories: package_tags[:dirnames]
}.merge(extract_static_attributes)
end
diff --git a/app/services/packages/rpm/repository_metadata/base_builder.rb b/app/services/packages/rpm/repository_metadata/base_builder.rb
deleted file mode 100644
index 2c0a11457ec..00000000000
--- a/app/services/packages/rpm/repository_metadata/base_builder.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-module Packages
- module Rpm
- module RepositoryMetadata
- class BaseBuilder
- def initialize(xml: nil, data: {})
- @xml = Nokogiri::XML(xml) if xml.present?
- @data = data
- end
-
- def execute
- return build_empty_structure if xml.blank?
-
- update_xml_document
- update_package_count
- xml.to_xml
- end
-
- private
-
- attr_reader :xml, :data
-
- def build_empty_structure
- Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
- xml.method_missing(self.class::ROOT_TAG, self.class::ROOT_ATTRIBUTES)
- end.to_xml
- end
-
- def update_xml_document
- # Add to the root xml element a new package metadata node
- xml.at(self.class::ROOT_TAG).add_child(build_new_node)
- end
-
- def update_package_count
- packages_count = xml.css("//#{self.class::ROOT_TAG}/package").count
-
- xml.at(self.class::ROOT_TAG).attributes["packages"].value = packages_count.to_s
- end
-
- def build_new_node
- raise NotImplementedError, "#{self.class} should implement #{__method__}"
- end
- end
- end
- end
-end
diff --git a/app/services/packages/rpm/repository_metadata/build_filelist_xml.rb b/app/services/packages/rpm/repository_metadata/build_filelist_xml.rb
deleted file mode 100644
index 01fb36f4b91..00000000000
--- a/app/services/packages/rpm/repository_metadata/build_filelist_xml.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# frozen_string_literal: true
-module Packages
- module Rpm
- module RepositoryMetadata
- class BuildFilelistXml < ::Packages::Rpm::RepositoryMetadata::BaseBuilder
- ROOT_TAG = 'filelists'
- ROOT_ATTRIBUTES = {
- xmlns: 'http://linux.duke.edu/metadata/filelists',
- packages: '0'
- }.freeze
- end
- end
- end
-end
diff --git a/app/services/packages/rpm/repository_metadata/build_filelist_xml_service.rb b/app/services/packages/rpm/repository_metadata/build_filelist_xml_service.rb
new file mode 100644
index 00000000000..47cbba76fa4
--- /dev/null
+++ b/app/services/packages/rpm/repository_metadata/build_filelist_xml_service.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+module Packages
+ module Rpm
+ module RepositoryMetadata
+ class BuildFilelistXmlService < BuildXmlBaseService
+ ROOT_TAG = 'filelists'
+ ROOT_ATTRIBUTES = {
+ xmlns: 'http://linux.duke.edu/metadata/filelists',
+ packages: '0'
+ }.freeze
+
+ def execute
+ super do |xml|
+ xml.package(pkgid: data[:pkgid], name: data[:name], arch: data[:arch]) do
+ xml.version epoch: data[:epoch], ver: data[:version], rel: data[:release]
+ build_file_nodes(xml)
+ end
+ end
+ end
+
+ private
+
+ def build_file_nodes(xml)
+ data[:files].each do |path|
+ attributes = dir?(path) ? { type: 'dir' } : {}
+
+ xml.file path, **attributes
+ end
+ end
+
+ def dir?(path)
+ # Add trailing slash to path to check
+ # if it exists in directories list
+ data[:directories].include? File.join(path, '')
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/packages/rpm/repository_metadata/build_other_xml.rb b/app/services/packages/rpm/repository_metadata/build_other_xml.rb
deleted file mode 100644
index 4bf61c901a3..00000000000
--- a/app/services/packages/rpm/repository_metadata/build_other_xml.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# frozen_string_literal: true
-module Packages
- module Rpm
- module RepositoryMetadata
- class BuildOtherXml < ::Packages::Rpm::RepositoryMetadata::BaseBuilder
- ROOT_TAG = 'otherdata'
- ROOT_ATTRIBUTES = {
- xmlns: 'http://linux.duke.edu/metadata/other',
- packages: '0'
- }.freeze
- end
- end
- end
-end
diff --git a/app/services/packages/rpm/repository_metadata/build_other_xml_service.rb b/app/services/packages/rpm/repository_metadata/build_other_xml_service.rb
new file mode 100644
index 00000000000..00e88f4f548
--- /dev/null
+++ b/app/services/packages/rpm/repository_metadata/build_other_xml_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+module Packages
+ module Rpm
+ module RepositoryMetadata
+ class BuildOtherXmlService < BuildXmlBaseService
+ ROOT_TAG = 'otherdata'
+ ROOT_ATTRIBUTES = {
+ xmlns: 'http://linux.duke.edu/metadata/other',
+ packages: '0'
+ }.freeze
+
+ def execute
+ super do |xml|
+ xml.package(pkgid: data[:pkgid], name: data[:name], arch: data[:arch]) do
+ xml.version epoch: data[:epoch], ver: data[:version], rel: data[:release]
+ build_changelog_nodes(xml)
+ end
+ end
+ end
+
+ private
+
+ def build_changelog_nodes(xml)
+ data[:changelogs].each do |changelog|
+ xml.changelog changelog[:changelogtext], date: changelog[:changelogtime]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/packages/rpm/repository_metadata/build_primary_xml.rb b/app/services/packages/rpm/repository_metadata/build_primary_xml_service.rb
index 580bf844a0c..1044ab3997a 100644
--- a/app/services/packages/rpm/repository_metadata/build_primary_xml.rb
+++ b/app/services/packages/rpm/repository_metadata/build_primary_xml_service.rb
@@ -2,7 +2,7 @@
module Packages
module Rpm
module RepositoryMetadata
- class BuildPrimaryXml < ::Packages::Rpm::RepositoryMetadata::BaseBuilder
+ class BuildPrimaryXmlService < BuildXmlBaseService
ROOT_TAG = 'metadata'
ROOT_ATTRIBUTES = {
xmlns: 'http://linux.duke.edu/metadata/common',
@@ -11,37 +11,27 @@ module Packages
}.freeze
# Nodes that have only text without attributes
- REQUIRED_BASE_ATTRIBUTES = %i[name arch summary description].freeze
- NOT_REQUIRED_BASE_ATTRIBUTES = %i[url packager].freeze
+ BASE_ATTRIBUTES = %i[name arch summary description url packager].freeze
FORMAT_NODE_BASE_ATTRIBUTES = %i[license vendor group buildhost sourcerpm].freeze
- private
-
- def build_new_node
- builder = Nokogiri::XML::Builder.new do |xml|
+ def execute
+ super do |xml|
xml.package(type: :rpm, 'xmlns:rpm': 'http://linux.duke.edu/metadata/rpm') do
- build_required_base_attributes(xml)
- build_not_required_base_attributes(xml)
+ build_base_attributes(xml)
xml.version epoch: data[:epoch], ver: data[:version], rel: data[:release]
- xml.checksum data[:checksum], type: 'sha256', pkgid: 'YES'
+ xml.checksum data[:pkgid], type: 'sha256', pkgid: 'YES'
xml.size package: data[:packagesize], installed: data[:installedsize], archive: data[:archivesize]
xml.time file: data[:filetime], build: data[:buildtime]
xml.location href: data[:location] if data[:location].present?
build_format_node(xml)
end
end
-
- Nokogiri::XML(builder.to_xml).at('package')
end
- def build_required_base_attributes(xml)
- REQUIRED_BASE_ATTRIBUTES.each do |attribute|
- xml.method_missing(attribute, data[attribute])
- end
- end
+ private
- def build_not_required_base_attributes(xml)
- NOT_REQUIRED_BASE_ATTRIBUTES.each do |attribute|
+ def build_base_attributes(xml)
+ BASE_ATTRIBUTES.each do |attribute|
xml.method_missing(attribute, data[attribute]) if data[attribute].present?
end
end
diff --git a/app/services/packages/rpm/repository_metadata/build_repomd_xml.rb b/app/services/packages/rpm/repository_metadata/build_repomd_xml_service.rb
index 84614196254..cb80faa12c0 100644
--- a/app/services/packages/rpm/repository_metadata/build_repomd_xml.rb
+++ b/app/services/packages/rpm/repository_metadata/build_repomd_xml_service.rb
@@ -2,9 +2,7 @@
module Packages
module Rpm
module RepositoryMetadata
- class BuildRepomdXml
- attr_reader :data
-
+ class BuildRepomdXmlService
ROOT_ATTRIBUTES = {
xmlns: 'http://linux.duke.edu/metadata/repo',
'xmlns:rpm': 'http://linux.duke.edu/metadata/rpm'
@@ -26,12 +24,6 @@ module Packages
end
def execute
- build_repomd
- end
-
- private
-
- def build_repomd
Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
xml.repomd(ROOT_ATTRIBUTES) do
xml.revision Time.now.to_i
@@ -40,6 +32,10 @@ module Packages
end.to_xml
end
+ private
+
+ attr_reader :data
+
def build_data_info(xml)
data.each do |filename, info|
xml.data(type: filename) do
diff --git a/app/services/packages/rpm/repository_metadata/build_xml_base_service.rb b/app/services/packages/rpm/repository_metadata/build_xml_base_service.rb
new file mode 100644
index 00000000000..4dfb4087f1b
--- /dev/null
+++ b/app/services/packages/rpm/repository_metadata/build_xml_base_service.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+module Packages
+ module Rpm
+ module RepositoryMetadata
+ class BuildXmlBaseService
+ def initialize(data)
+ @data = data
+ end
+
+ def execute
+ builder = Nokogiri::XML::Builder.new { |xml| yield xml } # rubocop:disable Style/ExplicitBlockArgument
+
+ Nokogiri::XML(builder.to_xml).at('package')
+ end
+
+ private
+
+ attr_reader :data
+ end
+ end
+ end
+end
diff --git a/app/services/packages/rpm/repository_metadata/update_xml_service.rb b/app/services/packages/rpm/repository_metadata/update_xml_service.rb
new file mode 100644
index 00000000000..8fef425195f
--- /dev/null
+++ b/app/services/packages/rpm/repository_metadata/update_xml_service.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+module Packages
+ module Rpm
+ module RepositoryMetadata
+ class UpdateXmlService
+ BUILDERS = {
+ other: ::Packages::Rpm::RepositoryMetadata::BuildOtherXmlService,
+ primary: ::Packages::Rpm::RepositoryMetadata::BuildPrimaryXmlService,
+ filelist: ::Packages::Rpm::RepositoryMetadata::BuildFilelistXmlService
+ }.freeze
+
+ def initialize(filename:, xml: nil, data: {})
+ @builder_class = BUILDERS[filename]
+ raise ArgumentError, "Filename must be one of: #{BUILDERS.keys.join(', ')}" if @builder_class.nil?
+
+ @xml = Nokogiri::XML(xml) if xml.present?
+ @data = data
+ @filename = filename
+ end
+
+ def execute
+ return build_empty_structure if xml.blank?
+
+ remove_existing_packages
+ update_xml_document
+ update_package_count
+ xml.to_xml
+ end
+
+ private
+
+ attr_reader :xml, :data, :builder_class, :filename
+
+ def build_empty_structure
+ Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
+ xml.method_missing(builder_class::ROOT_TAG, builder_class::ROOT_ATTRIBUTES)
+ end.to_xml
+ end
+
+ def update_xml_document
+ # Add to the root xml element a new package metadata node
+ xml.at(builder_class::ROOT_TAG).add_child(builder_class.new(data).execute)
+ end
+
+ def update_package_count
+ packages_count = xml.css("//#{builder_class::ROOT_TAG}/package").count
+
+ xml.at(builder_class::ROOT_TAG).attributes["packages"].value = packages_count.to_s
+ end
+
+ def remove_existing_packages
+ case filename
+ when :primary
+ xml.search("checksum:contains('#{data[:pkgid]}')").each { _1.parent&.remove }
+ else
+ xml.search("[pkgid='#{data[:pkgid]}']").each(&:remove)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/personal_access_tokens/revoke_service.rb b/app/services/personal_access_tokens/revoke_service.rb
index 732da75da3a..5371b6c91ef 100644
--- a/app/services/personal_access_tokens/revoke_service.rb
+++ b/app/services/personal_access_tokens/revoke_service.rb
@@ -4,7 +4,7 @@ module PersonalAccessTokens
class RevokeService < BaseService
attr_reader :token, :current_user, :group
- def initialize(current_user = nil, token: nil, group: nil )
+ def initialize(current_user = nil, token: nil, group: nil)
@current_user = current_user
@token = token
@group = group
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 c82ed97203f..c91103f897f 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
@@ -95,10 +95,12 @@ module Projects
end
def request_body(oids)
- {
+ body = {
operation: DOWNLOAD_ACTION,
objects: oids.map { |oid, size| { oid: oid, size: size } }
- }.to_json
+ }
+
+ Gitlab::Json.dump(body)
end
def headers
diff --git a/app/services/projects/move_users_star_projects_service.rb b/app/services/projects/move_users_star_projects_service.rb
index 5490448553f..4f1580c5f53 100644
--- a/app/services/projects/move_users_star_projects_service.rb
+++ b/app/services/projects/move_users_star_projects_service.rb
@@ -12,8 +12,8 @@ module Projects
Project.transaction do
user_stars.update_all(project_id: @project.id)
- Project.reset_counters @project.id, :users_star_projects
- Project.reset_counters source_project.id, :users_star_projects
+ @project.update(star_count: @project.starrers.with_state(:active).size)
+ source_project.update(star_count: source_project.starrers.with_state(:active).size)
success
end
diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb
index 9f260345937..1e084c0e5eb 100644
--- a/app/services/projects/prometheus/alerts/notify_service.rb
+++ b/app/services/projects/prometheus/alerts/notify_service.rb
@@ -36,9 +36,9 @@ module Projects
truncate_alerts! if max_alerts_exceeded?
- alert_responses = process_prometheus_alerts
+ process_prometheus_alerts
- alert_response(alert_responses)
+ created
end
def self.processable?(payload)
@@ -152,12 +152,6 @@ module Projects
.execute
end
end
-
- def alert_response(alert_responses)
- alerts = alert_responses.flat_map { |resp| resp.payload[:alerts] }.compact
-
- success(alerts)
- end
end
end
end
diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb
index 9eccc16a8b2..898421364db 100644
--- a/app/services/projects/unlink_fork_service.rb
+++ b/app/services/projects/unlink_fork_service.rb
@@ -60,3 +60,5 @@ module Projects
end
end
end
+
+Projects::UnlinkForkService.prepend_mod_with('Projects::UnlinkForkService')
diff --git a/app/services/protected_branches/api_service.rb b/app/services/protected_branches/api_service.rb
index f604a57bcd1..b8fe9bac13e 100644
--- a/app/services/protected_branches/api_service.rb
+++ b/app/services/protected_branches/api_service.rb
@@ -6,17 +6,32 @@ module ProtectedBranches
::ProtectedBranches::CreateService.new(@project, @current_user, protected_branch_params).execute
end
- def protected_branch_params
- {
- name: params[:name],
- allow_force_push: allow_force_push?,
- push_access_levels_attributes: ::ProtectedRefs::AccessLevelParams.new(:push, params).access_levels,
- merge_access_levels_attributes: ::ProtectedRefs::AccessLevelParams.new(:merge, params).access_levels
- }
+ def update(protected_branch)
+ ::ProtectedBranches::UpdateService.new(@project, @current_user,
+protected_branch_params(with_defaults: false)).execute(protected_branch)
end
- def allow_force_push?
- params[:allow_force_push] || false
+ private
+
+ def protected_branch_params(with_defaults: true)
+ params.slice(*attributes).merge(
+ {
+ push_access_levels_attributes: access_level_attributes(:push, with_defaults),
+ merge_access_levels_attributes: access_level_attributes(:merge, with_defaults)
+ }
+ )
+ end
+
+ def access_level_attributes(type, with_defaults)
+ ::ProtectedRefs::AccessLevelParams.new(
+ type,
+ params,
+ with_defaults: with_defaults
+ ).access_levels
+ end
+
+ def attributes
+ [:name, :allow_force_push]
end
end
end
diff --git a/app/services/protected_branches/cache_service.rb b/app/services/protected_branches/cache_service.rb
index 8c521f4ebcb..66ca549c508 100644
--- a/app/services/protected_branches/cache_service.rb
+++ b/app/services/protected_branches/cache_service.rb
@@ -7,20 +7,26 @@ module ProtectedBranches
CACHE_EXPIRE_IN = 1.day
CACHE_LIMIT = 1000
- def fetch(ref_name, dry_run: false)
+ def fetch(ref_name, dry_run: false, &block)
record = OpenSSL::Digest::SHA256.hexdigest(ref_name)
- Gitlab::Redis::Cache.with do |redis|
+ with_redis do |redis|
cached_result = redis.hget(redis_key, record)
- decoded_result = Gitlab::Redis::Boolean.decode(cached_result) unless cached_result.nil?
+ if cached_result.nil?
+ metrics.increment_cache_miss
+ else
+ metrics.increment_cache_hit
+
+ decoded_result = Gitlab::Redis::Boolean.decode(cached_result)
+ end
# If we're dry-running, don't break because we need to check against
# the real value to ensure the cache is working properly.
# If the result is nil we'll need to run the block, so don't break yet.
break decoded_result unless dry_run || decoded_result.nil?
- calculated_value = yield
+ calculated_value = metrics.observe_cache_generation(&block)
check_and_log_discrepancy(decoded_result, calculated_value, ref_name) if dry_run
@@ -42,11 +48,15 @@ module ProtectedBranches
end
def refresh
- Gitlab::Redis::Cache.with { |redis| redis.unlink(redis_key) }
+ with_redis { |redis| redis.unlink(redis_key) }
end
private
+ def with_redis(&block)
+ Gitlab::Redis::Cache.with(&block) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
def check_and_log_discrepancy(cached_value, real_value, ref_name)
return if cached_value.nil?
return if cached_value == real_value
@@ -64,5 +74,14 @@ module ProtectedBranches
def redis_key
@redis_key ||= [CACHE_ROOT_KEY, @project.id].join(':')
end
+
+ def metrics
+ @metrics ||= Gitlab::Cache::Metrics.new(
+ caller_id: Gitlab::ApplicationContext.current_context_attribute(:caller_id),
+ cache_identifier: "#{self.class}#fetch",
+ feature_category: :source_code_management,
+ backing_resource: :cpu
+ )
+ end
end
end
diff --git a/app/services/protected_refs/access_level_params.rb b/app/services/protected_refs/access_level_params.rb
index 59fc17868d1..a421964a6ab 100644
--- a/app/services/protected_refs/access_level_params.rb
+++ b/app/services/protected_refs/access_level_params.rb
@@ -4,9 +4,9 @@ module ProtectedRefs
class AccessLevelParams
attr_reader :type, :params
- def initialize(type, params)
+ def initialize(type, params, with_defaults: true)
@type = type
- @params = params_with_default(params)
+ @params = with_defaults ? params_with_default(params) : params
end
def access_levels
diff --git a/app/services/resource_events/base_change_timebox_service.rb b/app/services/resource_events/base_change_timebox_service.rb
index 372f1c9d816..ba7c9d90713 100644
--- a/app/services/resource_events/base_change_timebox_service.rb
+++ b/app/services/resource_events/base_change_timebox_service.rb
@@ -12,11 +12,15 @@ module ResourceEvents
def execute
create_event
+ track_event
+
resource.expire_note_etag_cache
end
private
+ def track_event; end
+
def create_event
raise NotImplementedError
end
diff --git a/app/services/resource_events/change_milestone_service.rb b/app/services/resource_events/change_milestone_service.rb
index 24935a3327a..a092d807d8f 100644
--- a/app/services/resource_events/change_milestone_service.rb
+++ b/app/services/resource_events/change_milestone_service.rb
@@ -13,6 +13,12 @@ module ResourceEvents
private
+ def track_event
+ return unless resource.is_a?(WorkItem)
+
+ Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter.track_work_item_milestone_changed_action(author: user)
+ end
+
def create_event
ResourceMilestoneEvent.create(build_resource_args)
end
diff --git a/app/services/service_ping/submit_service.rb b/app/services/service_ping/submit_service.rb
index 7fd0fb10b4b..da2a51562f8 100644
--- a/app/services/service_ping/submit_service.rb
+++ b/app/services/service_ping/submit_service.rb
@@ -63,7 +63,7 @@ module ServicePing
def submit_payload(payload, path: USAGE_DATA_PATH)
Gitlab::HTTP.post(
URI.join(base_url, path),
- body: payload.to_json,
+ body: Gitlab::Json.dump(payload),
allow_local_requests: true,
headers: { 'Content-type' => 'application/json' }
)
diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb
index e0bab4cd6ad..5cadff42958 100644
--- a/app/services/snippets/create_service.rb
+++ b/app/services/snippets/create_service.rb
@@ -34,7 +34,7 @@ module Snippets
move_temporary_files
- ServiceResponse.success(payload: { snippet: @snippet } )
+ ServiceResponse.success(payload: { snippet: @snippet })
else
snippet_error_response(@snippet, 400)
end
diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb
index 08634ec840c..0dcb3546034 100644
--- a/app/services/spam/spam_verdict_service.rb
+++ b/app/services/spam/spam_verdict_service.rb
@@ -24,7 +24,7 @@ module Spam
label = spamcheck_error ? 'ERROR' : spamcheck_result.to_s.upcase
- histogram.observe( { result: label }, external_spam_check_round_trip_time )
+ 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
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
index 7275a05d2ce..ad9f0dd0368 100644
--- a/app/services/system_notes/issuables_service.rb
+++ b/app/services/system_notes/issuables_service.rb
@@ -16,6 +16,8 @@ module SystemNotes
def self.issuable_events
{
+ assigned: s_('IssuableEvents|assigned to'),
+ unassigned: s_('IssuableEvents|unassigned'),
review_requested: s_('IssuableEvents|requested review from'),
review_request_removed: s_('IssuableEvents|removed review request for')
}.freeze
@@ -83,7 +85,7 @@ module SystemNotes
#
# "assigned to @user1 additionally to @user2"
#
- # "assigned to @user1, @user2 and @user3 and unassigned from @user4 and @user5"
+ # "assigned to @user1, @user2 and @user3 and unassigned @user4 and @user5"
#
# "assigned to @user1 and @user2"
#
@@ -94,8 +96,8 @@ module SystemNotes
text_parts = []
Gitlab::I18n.with_default_locale do
- text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any?
- text_parts << "unassigned #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any?
+ text_parts << "#{self.class.issuable_events[:assigned]} #{added_users.map(&:to_reference).to_sentence}" if added_users.any?
+ text_parts << "#{self.class.issuable_events[:unassigned]} #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any?
end
body = text_parts.join(' and ')
diff --git a/app/services/tags/create_service.rb b/app/services/tags/create_service.rb
index 8a7b98ab944..e332b51ae94 100644
--- a/app/services/tags/create_service.rb
+++ b/app/services/tags/create_service.rb
@@ -3,6 +3,8 @@
module Tags
class CreateService < BaseService
def execute(tag_name, target, message)
+ return error('Target is empty', 400) if target.blank?
+
valid_tag = Gitlab::GitRefValidator.validate(tag_name)
return error('Tag name invalid', 400) unless valid_tag
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index 6ae394072c6..06352d36215 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -329,11 +329,12 @@ class TodoService
commit_id: nil
}
- if target.is_a?(Commit)
+ case target
+ when Commit
attributes.merge!(target_id: nil, commit_id: target.id)
- elsif target.is_a?(Issue)
+ when Issue
attributes[:issue_type] = target.issue_type
- elsif target.is_a?(Discussion)
+ when Discussion
attributes.merge!(target_type: nil, target_id: nil, discussion: target)
end
diff --git a/app/services/two_factor/base_service.rb b/app/services/two_factor/base_service.rb
index 0957d7ebabd..50a3a5c099c 100644
--- a/app/services/two_factor/base_service.rb
+++ b/app/services/two_factor/base_service.rb
@@ -4,12 +4,12 @@ module TwoFactor
class BaseService
include BaseServiceUtility
- attr_reader :current_user, :params, :user
+ attr_reader :current_user, :user, :group
def initialize(current_user, params = {})
@current_user = current_user
- @params = params
@user = params.delete(:user)
+ @group = params.delete(:group)
end
end
end
diff --git a/app/services/two_factor/destroy_service.rb b/app/services/two_factor/destroy_service.rb
index 859012c2153..3db9aefbe70 100644
--- a/app/services/two_factor/destroy_service.rb
+++ b/app/services/two_factor/destroy_service.rb
@@ -3,7 +3,7 @@
module TwoFactor
class DestroyService < ::TwoFactor::BaseService
def execute
- return error(_('You are not authorized to perform this action')) unless can?(current_user, :disable_two_factor, user)
+ return error(_('You are not authorized to perform this action')) unless authorized?
return error(_('Two-factor authentication is not enabled for this user')) unless user.two_factor_enabled?
result = disable_two_factor
@@ -15,6 +15,10 @@ module TwoFactor
private
+ def authorized?
+ can?(current_user, :disable_two_factor, user)
+ end
+
def disable_two_factor
::Users::UpdateService.new(current_user, user: user).execute do |user|
user.disable_two_factor!
diff --git a/app/services/user_project_access_changed_service.rb b/app/services/user_project_access_changed_service.rb
index ceaf21bb926..f7178ee9bb6 100644
--- a/app/services/user_project_access_changed_service.rb
+++ b/app/services/user_project_access_changed_service.rb
@@ -21,9 +21,10 @@ class UserProjectAccessChangedService
if blocking
AuthorizedProjectsWorker.bulk_perform_and_wait(bulk_args)
else
- if priority == HIGH_PRIORITY
+ case priority
+ when HIGH_PRIORITY
AuthorizedProjectsWorker.bulk_perform_async(bulk_args) # rubocop:disable Scalability/BulkPerformWithContext
- elsif priority == MEDIUM_PRIORITY
+ when MEDIUM_PRIORITY
AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker.bulk_perform_in(MEDIUM_DELAY, bulk_args, batch_size: 100, batch_delay: 30.seconds) # rubocop:disable Scalability/BulkPerformWithContext
else
with_related_class_context do
diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb
index 0fa1bb96b13..8ef1b3e0613 100644
--- a/app/services/users/build_service.rb
+++ b/app/services/users/build_service.rb
@@ -177,7 +177,7 @@ module Users
# Allowed params for user signup
def signup_params
- [
+ signup_params = [
:email,
:name,
:password,
@@ -187,6 +187,9 @@ module Users
:first_name,
:last_name
]
+ signup_params << :preferred_language if ::Feature.enabled?(:preferred_language_switcher)
+
+ signup_params
end
end
end
diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb
index a378cb09854..d4c00a4dcec 100644
--- a/app/services/users/destroy_service.rb
+++ b/app/services/users/destroy_service.rb
@@ -8,9 +8,20 @@ module Users
def initialize(current_user)
@current_user = current_user
+
+ @scheduled_records_gauge = Gitlab::Metrics.gauge(
+ :gitlab_ghost_user_migration_scheduled_records_total,
+ 'The total number of scheduled ghost user migrations'
+ )
+ @lag_gauge = Gitlab::Metrics.gauge(
+ :gitlab_ghost_user_migration_lag_seconds,
+ 'The waiting time in seconds of the oldest scheduled record for ghost user migration'
+ )
end
- # Synchronously destroys +user+
+ # Asynchronously destroys +user+
+ # Migrating the associated user records, and post-migration cleanup is
+ # handled by the Users::MigrateRecordsToGhostUserWorker cron worker.
#
# The operation will fail if the user is the sole owner of any groups. To
# force the groups to be destroyed, pass `delete_solo_owned_groups: true` in
@@ -24,10 +35,7 @@ module Users
# a hard deletion without destroying solo-owned groups, pass
# `delete_solo_owned_groups: false, hard_delete: true` in +options+.
#
- # To make the service asynchronous, a new behaviour is being introduced
- # behind the user_destroy_with_limited_execution_time_worker feature flag.
- # Migrating the associated user records, and post-migration cleanup is
- # handled by the Users::MigrateRecordsToGhostUserWorker cron worker.
+
def execute(user, options = {})
delete_solo_owned_groups = options.fetch(:delete_solo_owned_groups, options[:hard_delete])
@@ -62,32 +70,43 @@ module Users
yield(user) if block_given?
hard_delete = options.fetch(:hard_delete, false)
+ Users::GhostUserMigration.create!(user: user,
+ initiator_user: current_user,
+ hard_delete: hard_delete)
- if Feature.enabled?(:user_destroy_with_limited_execution_time_worker)
- Users::GhostUserMigration.create!(user: user,
- initiator_user: current_user,
- hard_delete: hard_delete)
+ update_metrics
+ end
- else
- MigrateToGhostUserService.new(user).execute(hard_delete: options[:hard_delete])
+ private
+
+ attr_reader :scheduled_records_gauge, :lag_gauge
- response = Snippets::BulkDestroyService.new(current_user, user.snippets)
- .execute(skip_authorization: hard_delete)
- raise DestroyError, response.message if response.error?
+ def update_metrics
+ update_scheduled_records_gauge
+ update_lag_gauge
+ end
- # Rails attempts to load all related records into memory before
- # destroying: https://github.com/rails/rails/issues/22510
- # This ensures we delete records in batches.
- user.destroy_dependent_associations_in_batches(exclude: [:snippets])
- user.nullify_dependent_associations_in_batches
+ def update_scheduled_records_gauge
+ # We do not want to issue unbounded COUNT() queries, hence we limit the
+ # query to count 1001 records and then approximate the result.
+ count = Users::GhostUserMigration.limit(1001).count
- # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
- user_data = user.destroy
- namespace.destroy
+ if count == 1001
+ # more than 1000 records, approximate count
+ min = Users::GhostUserMigration.minimum(:id) || 0
+ max = Users::GhostUserMigration.maximum(:id) || 0
- user_data
+ scheduled_records_gauge.set({}, max - min)
+ else
+ # less than 1000 records, count is accurate
+ scheduled_records_gauge.set({}, count)
end
end
+
+ def update_lag_gauge
+ oldest_job = Users::GhostUserMigration.first
+ lag_gauge.set({}, Time.current - oldest_job.created_at)
+ end
end
end
diff --git a/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb b/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb
index 7c4a5698ea9..d294312cc30 100644
--- a/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb
+++ b/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb
@@ -2,25 +2,38 @@
module Users
class MigrateRecordsToGhostUserInBatchesService
+ LIMIT_SIZE = 1000
+
def initialize
@execution_tracker = Gitlab::Utils::ExecutionTracker.new
end
def execute
- Users::GhostUserMigration.find_each do |user_to_migrate|
+ ghost_user_migrations.each do |job|
break if execution_tracker.over_limit?
- service = Users::MigrateRecordsToGhostUserService.new(user_to_migrate.user,
- user_to_migrate.initiator_user,
+ service = Users::MigrateRecordsToGhostUserService.new(job.user,
+ job.initiator_user,
execution_tracker)
- service.execute(hard_delete: user_to_migrate.hard_delete)
+ service.execute(hard_delete: job.hard_delete)
+ rescue Gitlab::Utils::ExecutionTracker::ExecutionTimeOutError
+ # no-op
+ rescue StandardError => e
+ ::Gitlab::ErrorTracking.track_exception(e)
+ reschedule(job)
end
- rescue Gitlab::Utils::ExecutionTracker::ExecutionTimeOutError
- # no-op
end
private
attr_reader :execution_tracker
+
+ def ghost_user_migrations
+ Users::GhostUserMigration.consume_order.limit(LIMIT_SIZE)
+ end
+
+ def reschedule(job)
+ job.update(consume_after: 30.minutes.from_now)
+ end
end
end
diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb
deleted file mode 100644
index 3eb220c0e40..00000000000
--- a/app/services/users/migrate_to_ghost_user_service.rb
+++ /dev/null
@@ -1,113 +0,0 @@
-# frozen_string_literal: true
-
-# When a user is destroyed, some of their associated records are
-# moved to a "Ghost User", to prevent these associated records from
-# being destroyed.
-#
-# For example, all the issues/MRs a user has created are _not_ destroyed
-# when the user is destroyed.
-module Users
- class MigrateToGhostUserService
- extend ActiveSupport::Concern
-
- attr_reader :ghost_user, :user, :hard_delete
-
- def initialize(user)
- @user = user
- @ghost_user = User.ghost
- end
-
- # If an admin attempts to hard delete a user, in some cases associated
- # records may have a NOT NULL constraint on the user ID that prevent that record
- # from being destroyed. In such situations we must assign the record to the ghost user.
- # Passing in `hard_delete: true` will ensure these records get assigned to
- # the ghost user before the user is destroyed. Other associated records will be destroyed.
- # letting the other associated records be destroyed.
- def execute(hard_delete: false)
- @hard_delete = hard_delete
- transition = user.block_transition
-
- # Block the user before moving records to prevent a data race.
- # For example, if the user creates an issue after `migrate_issues`
- # runs and before the user is destroyed, the destroy will fail with
- # an exception.
- user.block
-
- begin
- user.transaction do
- migrate_records
- end
- rescue Exception # rubocop:disable Lint/RescueException
- # Reverse the user block if record migration fails
- if transition
- transition.rollback
- user.save!
- end
-
- raise
- end
-
- user.reset
- end
-
- private
-
- def migrate_records
- return if hard_delete
-
- migrate_issues
- migrate_merge_requests
- migrate_notes
- migrate_abuse_reports
- migrate_award_emoji
- migrate_snippets
- migrate_reviews
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def migrate_issues
- batched_migrate(Issue, :author_id)
- batched_migrate(Issue, :last_edited_by_id)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- # rubocop: disable CodeReuse/ActiveRecord
- def migrate_merge_requests
- batched_migrate(MergeRequest, :author_id)
- batched_migrate(MergeRequest, :merge_user_id)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def migrate_notes
- batched_migrate(Note, :author_id)
- end
-
- def migrate_abuse_reports
- user.reported_abuse_reports.update_all(reporter_id: ghost_user.id)
- end
-
- def migrate_award_emoji
- user.award_emoji.update_all(user_id: ghost_user.id)
- end
-
- def migrate_snippets
- snippets = user.snippets.only_project_snippets
- snippets.update_all(author_id: ghost_user.id)
- end
-
- def migrate_reviews
- batched_migrate(Review, :author_id)
- end
-
- # rubocop:disable CodeReuse/ActiveRecord
- def batched_migrate(base_scope, column, batch_size: 50)
- loop do
- update_count = base_scope.where(column => user.id).limit(batch_size).update_all(column => ghost_user.id)
- break if update_count == 0
- end
- end
- # rubocop:enable CodeReuse/ActiveRecord
- end
-end
-
-Users::MigrateToGhostUserService.prepend_mod_with('Users::MigrateToGhostUserService')
diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb
index e5e5e375198..d32dcd73734 100644
--- a/app/services/web_hook_service.rb
+++ b/app/services/web_hook_service.rb
@@ -57,11 +57,11 @@ class WebHookService
end
def execute
- return { status: :error, message: 'Hook disabled' } if disabled?
+ return ServiceResponse.error(message: 'Hook disabled') if disabled?
if recursion_blocked?
log_recursion_blocked
- return { status: :error, message: 'Recursive webhook blocked' }
+ return ServiceResponse.error(message: 'Recursive webhook blocked')
end
Gitlab::WebHooks::RecursionDetection.register!(hook)
@@ -79,11 +79,7 @@ class WebHookService
execution_duration: Gitlab::Metrics::System.monotonic_time - start_time
)
- {
- status: :success,
- http_status: response.code,
- message: response.body
- }
+ ServiceResponse.success(message: response.body, payload: { http_status: response.code })
rescue *Gitlab::HTTP::HTTP_ERRORS,
Gitlab::Json::LimitedEncoder::LimitExceeded, URI::InvalidURIError => e
execution_duration = Gitlab::Metrics::System.monotonic_time - start_time
@@ -97,10 +93,7 @@ class WebHookService
Gitlab::AppLogger.error("WebHook Error after #{execution_duration.to_i.seconds}s => #{e}")
- {
- status: :error,
- message: error_message
- }
+ ServiceResponse.error(message: error_message)
end
def async_execute
diff --git a/app/services/work_items/create_service.rb b/app/services/work_items/create_service.rb
index ebda043e873..87cc690d666 100644
--- a/app/services/work_items/create_service.rb
+++ b/app/services/work_items/create_service.rb
@@ -30,6 +30,13 @@ module WorkItems
error(e.message, :unprocessable_entity)
end
+ def before_create(work_item)
+ execute_widgets(work_item: work_item, callback: :before_create_callback,
+ widget_params: @widget_params)
+
+ super
+ end
+
def transaction_create(work_item)
super.tap do |save_result|
if save_result
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 bb681ef0083..236762d6937 100644
--- a/app/services/work_items/widgets/hierarchy_service/base_service.rb
+++ b/app/services/work_items/widgets/hierarchy_service/base_service.rb
@@ -7,7 +7,6 @@ module WorkItems
private
def handle_hierarchy_changes(params)
- return feature_flag_error unless feature_flag_enabled?
return incompatible_args_error if incompatible_args?(params)
if params.key?(:parent)
@@ -48,24 +47,16 @@ module WorkItems
.execute
end
- def feature_flag_enabled?
- Feature.enabled?(:work_items_hierarchy, work_item&.project)
- end
-
def incompatible_args?(params)
params[:children] && params[:parent]
end
- def feature_flag_error
- error(_('`work_items_hierarchy` feature flag disabled for this project'))
- end
-
def incompatible_args_error
error(_('A Work Item can be a parent or a child, but not both.'))
end
def invalid_args_error(params)
- error(_("One or more arguments are invalid: %{args}." % { args: params.keys.to_sentence } ))
+ error(_("One or more arguments are invalid: %{args}." % { args: params.keys.to_sentence }))
end
def service_response!(result)
diff --git a/app/services/work_items/widgets/milestone_service/base_service.rb b/app/services/work_items/widgets/milestone_service/base_service.rb
new file mode 100644
index 00000000000..f373e6daea3
--- /dev/null
+++ b/app/services/work_items/widgets/milestone_service/base_service.rb
@@ -0,0 +1,39 @@
+# 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
new file mode 100644
index 00000000000..e8d6bfe503c
--- /dev/null
+++ b/app/services/work_items/widgets/milestone_service/create_service.rb
@@ -0,0 +1,13 @@
+# 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
new file mode 100644
index 00000000000..7ff0c2a5367
--- /dev/null
+++ b/app/services/work_items/widgets/milestone_service/update_service.rb
@@ -0,0 +1,13 @@
+# 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/uploaders/object_storage/cdn.rb b/app/uploaders/object_storage/cdn.rb
index e49e2780147..8c9ee8682f4 100644
--- a/app/uploaders/object_storage/cdn.rb
+++ b/app/uploaders/object_storage/cdn.rb
@@ -12,9 +12,9 @@ module ObjectStorage
UrlResult = Struct.new(:url, :used_cdn)
- def cdn_enabled_url(project, ip_address)
- if Feature.enabled?(:ci_job_artifacts_cdn, project) && use_cdn?(ip_address)
- UrlResult.new(cdn_signed_url, true)
+ def cdn_enabled_url(ip_address, params = {})
+ if use_cdn?(ip_address)
+ UrlResult.new(cdn_signed_url(params), true)
else
UrlResult.new(url, false)
end
@@ -27,8 +27,8 @@ module ObjectStorage
cdn_provider.use_cdn?(request_ip)
end
- def cdn_signed_url
- cdn_provider&.signed_url(path)
+ def cdn_signed_url(params = {})
+ cdn_provider&.signed_url(path, params: params)
end
private
diff --git a/app/uploaders/object_storage/cdn/google_cdn.rb b/app/uploaders/object_storage/cdn/google_cdn.rb
index 91bad1f8d6b..f1fe62e9db3 100644
--- a/app/uploaders/object_storage/cdn/google_cdn.rb
+++ b/app/uploaders/object_storage/cdn/google_cdn.rb
@@ -24,18 +24,24 @@ module ObjectStorage
!GoogleIpCache.google_ip?(request_ip)
end
- def signed_url(path, expiry: 10.minutes)
+ def signed_url(path, expiry: 10.minutes, params: {})
expiration = (Time.current + expiry).utc.to_i
uri = Addressable::URI.parse(cdn_url)
uri.path = path
- uri.query = "Expires=#{expiration}&KeyName=#{key_name}"
-
- signature = OpenSSL::HMAC.digest('SHA1', decoded_key, uri.to_s)
+ # 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
+ query_params = params.to_a
+ query_params << ['Expires', expiration]
+ query_params << ['KeyName', key_name]
+ uri.query_values = query_params
+
+ unsigned_url = uri.to_s
+ signature = OpenSSL::HMAC.digest('SHA1', decoded_key, unsigned_url)
encoded_signature = Base64.urlsafe_encode64(signature)
- uri.query += "&Signature=#{encoded_signature}"
- uri.to_s
+ "#{unsigned_url}&Signature=#{encoded_signature}"
end
private
diff --git a/app/validators/branch_filter_validator.rb b/app/validators/branch_filter_validator.rb
deleted file mode 100644
index 89d6343a9a4..00000000000
--- a/app/validators/branch_filter_validator.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-# BranchFilterValidator
-#
-# Custom validator for branch names. Squishes whitespace and ignores empty
-# string. This only checks that a string is a valid git branch name. It does
-# not check whether a branch already exists.
-#
-# Example:
-#
-# class Webhook < ActiveRecord::Base
-# validates :push_events_branch_filter, branch_name: true
-# end
-#
-class BranchFilterValidator < ActiveModel::EachValidator
- def validate_each(record, attribute, value)
- value.squish! unless value.nil?
-
- if value.present?
- value_without_wildcards = value.tr('*', 'x')
-
- unless Gitlab::GitRefValidator.validate(value_without_wildcards)
- record.errors.add(attribute, "is not a valid branch name")
- end
-
- unless value.length <= 4000
- record.errors.add(attribute, "is longer than the allowed length of 4000 characters.")
- end
- end
- end
-
- private
-
- def contains_wildcard?(value)
- value.include?('*')
- end
-end
diff --git a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json
index 19258ee7677..fb6b80e0725 100644
--- a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json
+++ b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json
@@ -1,176 +1,135 @@
{
- "$schema": "http://json-schema.org/draft-07/schema#",
- "global": [
- {
- "field": "SECURE_ANALYZERS_PREFIX",
- "label": "Image prefix",
- "type": "string",
- "default_value": "",
- "value": "",
- "size": "LARGE",
- "description": "Analyzer image's registry prefix (or name of the registry providing the analyzers' image)"
- },
- {
- "field" : "SAST_EXCLUDED_PATHS",
- "label" : "Excluded Paths",
- "type": "string",
- "default_value": "",
- "value": "",
- "size": "MEDIUM",
- "description": "Comma-separated list of paths to be excluded from analyzer output. Patterns can be globs, file paths, or folder paths."
- }
- ],
- "pipeline": [
- {
- "field" : "stage",
- "label" : "Stage",
- "type": "string",
- "default_value": "",
- "value": "",
- "size": "MEDIUM",
- "description": "Pipeline stage in which the scan jobs run"
- },
- {
- "field" : "SEARCH_MAX_DEPTH",
- "label" : "Search maximum depth",
- "type": "string",
- "default_value": "",
- "value": "",
- "size": "SMALL",
- "description": "Maximum depth of language and framework detection"
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "global": [
+ {
+ "field": "SECURE_ANALYZERS_PREFIX",
+ "label": "Image prefix",
+ "type": "string",
+ "default_value": "",
+ "value": "",
+ "size": "LARGE",
+ "description": "Analyzer image's registry prefix (or name of the registry providing the analyzers' image)"
+ },
+ {
+ "field": "SAST_EXCLUDED_PATHS",
+ "label": "Excluded Paths",
+ "type": "string",
+ "default_value": "",
+ "value": "",
+ "size": "MEDIUM",
+ "description": "Comma-separated list of paths to be excluded from analyzer output. Patterns can be globs, file paths, or folder paths."
+ }
+ ],
+ "pipeline": [
+ {
+ "field": "stage",
+ "label": "Stage",
+ "type": "string",
+ "default_value": "",
+ "value": "",
+ "size": "MEDIUM",
+ "description": "Pipeline stage in which the scan jobs run"
+ },
+ {
+ "field": "SEARCH_MAX_DEPTH",
+ "label": "Search maximum depth",
+ "type": "string",
+ "default_value": "",
+ "value": "",
+ "size": "SMALL",
+ "description": "Maximum depth of language and framework detection"
+ }
+ ],
+ "analyzers": [
+ {
+ "name": "brakeman",
+ "label": "Brakeman",
+ "enabled": true,
+ "description": "Ruby on Rails",
+ "variables": [
+ {
+ "field": "SAST_BRAKEMAN_LEVEL",
+ "label": "Brakeman confidence level",
+ "type": "string",
+ "default_value": "1",
+ "value": "",
+ "size": "SMALL",
+ "description": "Ignore Brakeman vulnerabilities under given confidence level. Integer, 1=Low, 2=Medium, 3=High."
}
- ],
- "analyzers": [
- {
- "name": "bandit",
- "label": "Bandit",
- "enabled" : true,
- "description": "Python",
- "variables": [
- {
- "field" : "SAST_BANDIT_EXCLUDED_PATHS",
- "label" : "Paths to exclude from scan",
- "type": "string",
- "default_value": "",
- "value": "",
- "size": "SMALL",
- "description": "Comma-separated list of paths to exclude from scan. Uses Python’s 'fnmatch' syntax; For example: '*/tests/*, */venv/*'"
- }
- ]
- },
- {
- "name": "brakeman",
- "label": "Brakeman",
- "enabled" : true,
- "description": "Ruby on Rails",
- "variables": [
- {
- "field" : "SAST_BRAKEMAN_LEVEL",
- "label" : "Brakeman confidence level",
- "type": "string",
- "default_value": "1",
- "value": "",
- "size": "SMALL",
- "description": "Ignore Brakeman vulnerabilities under given confidence level. Integer, 1=Low, 2=Medium, 3=High."
- }
- ]
- },
- {
- "name": "eslint",
- "label": "ESLint",
- "enabled" : true,
- "description": "JavaScript, TypeScript, React",
- "variables": []
- },
- {
- "name": "flawfinder",
- "label": "Flawfinder",
- "enabled" : true,
- "description": "C, C++",
- "variables": [
- {
- "field" : "SAST_FLAWFINDER_LEVEL",
- "label" : "Flawfinder risk level",
- "type": "string",
- "default_value": "1",
- "value": "",
- "size": "SMALL",
- "description": "Ignore Flawfinder vulnerabilities under given risk level. Integer, 0=No risk, 5=High risk."
- }
- ]
- },
- {
- "name": "gosec",
- "label": "Gosec",
- "enabled" : true,
- "description": "Go",
- "variables": [
- {
- "field" : "SAST_GOSEC_LEVEL",
- "label" : "Gosec confidence level",
- "type": "string",
- "default_value": "0",
- "value": "",
- "size": "SMALL",
- "description": "Ignore Gosec vulnerabilities under given confidence level. Integer, 0=Undefined, 1=Low, 2=Medium, 3=High."
- }
- ]
- },
- {
- "name": "kubesec",
- "label": "Kubesec",
- "enabled" : true,
- "description": "Kubernetes manifests, Helm Charts",
- "variables": []
- },
- {
- "name": "nodejs-scan",
- "label": "Node.js Scan",
- "enabled" : true,
- "description": "Node.js",
- "variables": []
- },
- {
- "name": "phpcs-security-audit",
- "label": "PHP Security Audit",
- "enabled" : true,
- "description": "PHP",
- "variables": []
- },
- {
- "name": "pmd-apex",
- "label": "PMD APEX",
- "enabled" : true,
- "description": "Apex (Salesforce)",
- "variables": []
- },
- {
- "name": "security-code-scan",
- "label": "Security Code Scan",
- "enabled" : true,
- "description": ".NET Core, .NET Framework",
- "variables": []
- },
- {
- "name": "semgrep",
- "label": "Semgrep",
- "enabled": true,
- "description": "Multi-language scanning",
- "variables": []
- },
- {
- "name": "sobelow",
- "label": "Sobelow",
- "enabled" : true,
- "description": "Elixir (Phoenix)",
- "variables": []
- },
- {
- "name": "spotbugs",
- "label": "Spotbugs",
- "enabled" : true,
- "description": "Groovy, Java, Scala",
- "variables": []
+ ]
+ },
+ {
+ "name": "flawfinder",
+ "label": "Flawfinder",
+ "enabled": true,
+ "description": "C, C++",
+ "variables": [
+ {
+ "field": "SAST_FLAWFINDER_LEVEL",
+ "label": "Flawfinder risk level",
+ "type": "string",
+ "default_value": "1",
+ "value": "",
+ "size": "SMALL",
+ "description": "Ignore Flawfinder vulnerabilities under given risk level. Integer, 0=No risk, 5=High risk."
}
- ]
-}
+ ]
+ },
+ {
+ "name": "kubesec",
+ "label": "Kubesec",
+ "enabled": true,
+ "description": "Kubernetes manifests, Helm Charts",
+ "variables": []
+ },
+ {
+ "name": "nodejs-scan",
+ "label": "Node.js Scan",
+ "enabled": true,
+ "description": "Node.js",
+ "variables": []
+ },
+ {
+ "name": "phpcs-security-audit",
+ "label": "PHP Security Audit",
+ "enabled": true,
+ "description": "PHP",
+ "variables": []
+ },
+ {
+ "name": "pmd-apex",
+ "label": "PMD APEX",
+ "enabled": true,
+ "description": "Apex (Salesforce)",
+ "variables": []
+ },
+ {
+ "name": "security-code-scan",
+ "label": "Security Code Scan",
+ "enabled": true,
+ "description": ".NET Core, .NET Framework",
+ "variables": []
+ },
+ {
+ "name": "semgrep",
+ "label": "Semgrep",
+ "enabled": true,
+ "description": "Multi-language scanning",
+ "variables": []
+ },
+ {
+ "name": "sobelow",
+ "label": "Sobelow",
+ "enabled": true,
+ "description": "Elixir (Phoenix)",
+ "variables": []
+ },
+ {
+ "name": "spotbugs",
+ "label": "Spotbugs",
+ "enabled": true,
+ "description": "Groovy, Java, Scala",
+ "variables": []
+ }
+ ]
+} \ No newline at end of file
diff --git a/app/validators/json_schemas/web_hooks_url_variables.json b/app/validators/json_schemas/web_hooks_url_variables.json
index d23a19bf47a..ea504d114e3 100644
--- a/app/validators/json_schemas/web_hooks_url_variables.json
+++ b/app/validators/json_schemas/web_hooks_url_variables.json
@@ -5,7 +5,7 @@
"additionalProperties": false,
"maxProperties": 20,
"patternProperties": {
- "^[A-Za-z_][A-Za-z0-9_]*$": {
+ "^[A-Za-z]+[0-9]*(?:[._-][A-Za-z0-9]+)*$": {
"type": "string",
"minLength": 1,
"maxLength": 100
diff --git a/app/validators/nested_attributes_duplicates_validator.rb b/app/validators/nested_attributes_duplicates_validator.rb
index b60350a6311..de219c300ba 100644
--- a/app/validators/nested_attributes_duplicates_validator.rb
+++ b/app/validators/nested_attributes_duplicates_validator.rb
@@ -25,11 +25,11 @@ class NestedAttributesDuplicatesValidator < ActiveModel::EachValidator
def validate_duplicates(record, attribute, values)
child_attributes.each do |child_attribute|
duplicates = values.reject(&:marked_for_destruction?).group_by(&:"#{child_attribute}").select { |_, v| v.many? }.map(&:first)
- if duplicates.any?
- error_message = +"have duplicate values (#{duplicates.join(", ")})"
- error_message << " for #{values.first.send(options[:scope])} scope" if options[:scope] # rubocop:disable GitlabSecurity/PublicSend
- record.errors.add(attribute, error_message)
- end
+ next unless duplicates.any?
+
+ error_message = +"have duplicate values (#{duplicates.join(", ")})"
+ error_message << " for #{values.first.send(options[:scope])} scope" if options[:scope] # rubocop:disable GitlabSecurity/PublicSend
+ record.errors.add(attribute, error_message)
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/validators/web_hooks/wildcard_branch_filter_validator.rb b/app/validators/web_hooks/wildcard_branch_filter_validator.rb
new file mode 100644
index 00000000000..12ec78f05de
--- /dev/null
+++ b/app/validators/web_hooks/wildcard_branch_filter_validator.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+# WildcardBranchFilterValidator
+#
+# Custom validator for wildcard branch filter. Squishes whitespace and ignores
+# empty string. This only checks that a string is a valid wildcard git branch
+# like "feature/login" and "feature/*". It doesn't check whether a branch already
+# exists.
+#
+# Example:
+#
+# class Webhook < ActiveRecord::Base
+# validates :push_events_branch_filter, "web_hooks/wildcard_branch_filter": true
+# end
+#
+module WebHooks
+ class WildcardBranchFilterValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ value.squish! unless value.nil?
+
+ return unless value.present?
+
+ value_without_wildcards = value.tr('*', 'x')
+
+ unless Gitlab::GitRefValidator.validate(value_without_wildcards)
+ record.errors.add(attribute, "is not a valid branch name")
+ end
+
+ return if value.length <= 4000
+
+ record.errors.add(attribute, "is longer than the allowed length of 4000 characters.")
+ end
+ end
+end
diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml
index c091a2180c5..0f7b10f822d 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -69,4 +69,6 @@
= render 'admin/application_settings/invitation_flow_enforcement', form: f
= render 'admin/application_settings/user_restrictions', form: f
= render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f
+ -# This is added for Jihu edition which should not be deleted without notifying Jihu
+ = render_if_exists 'admin/application_settings/password_expiration_setting', form: f
= f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_jira_connect_application_key.html.haml b/app/views/admin/application_settings/_jira_connect.html.haml
index b67e7680720..ad0660797ee 100644
--- a/app/views/admin/application_settings/_jira_connect_application_key.html.haml
+++ b/app/views/admin/application_settings/_jira_connect.html.haml
@@ -18,4 +18,10 @@
.form-group
= f.label :jira_connect_application_key, s_('JiraConnect|Jira Connect Application ID'), class: 'label-bold'
= f.text_field :jira_connect_application_key, class: 'form-control gl-form-input'
+
+ %fieldset
+ .form-group
+ = f.label :jira_connect_proxy_url, s_('JiraConnect|Jira Connect Proxy URL'), class: 'label-bold'
+ = f.text_field :jira_connect_proxy_url, class: 'form-control gl-form-input'
+
= f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/admin/application_settings/_package_registry.html.haml b/app/views/admin/application_settings/_package_registry.html.haml
index 3506038ca68..66b04006beb 100644
--- a/app/views/admin/application_settings/_package_registry.html.haml
+++ b/app/views/admin/application_settings/_package_registry.html.haml
@@ -6,7 +6,7 @@
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _("Control how the GitLab Package Registry functions.")
+ = s_('PackageRegistry|Configure package forwarding and package file size limits.')
= render_if_exists 'admin/application_settings/ee_package_registry'
diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml
index ef8d3ccc8ab..aaf76c5ff7a 100644
--- a/app/views/admin/application_settings/_repository_check.html.haml
+++ b/app/views/admin/application_settings/_repository_check.html.haml
@@ -18,8 +18,8 @@
.sub-section
%h4= _("Housekeeping")
.form-group
- - help_text = _("Leaving this setting enabled is recommended.")
- - help_link = link_to s_('Learn more.'), help_page_path('administration/housekeeping.md', anchor: 'housekeeping-options'), target: '_blank', rel: 'noopener noreferrer'
+ - help_text = _("Run housekeeping tasks to automatically optimize Git repositories. Disabling this option will cause performance to degenerate over time.")
+ - help_link = link_to s_('Learn more.'), help_page_path('administration/housekeeping.md', anchor: 'configure-push-based-maintenance'), target: '_blank', rel: 'noopener noreferrer'
= f.gitlab_ui_checkbox_component :housekeeping_enabled,
_("Enable automatic repository housekeeping"),
help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link }
diff --git a/app/views/admin/application_settings/_user_restrictions.html.haml b/app/views/admin/application_settings/_user_restrictions.html.haml
index de8faa6705f..82f5e6def9f 100644
--- a/app/views/admin/application_settings/_user_restrictions.html.haml
+++ b/app/views/admin/application_settings/_user_restrictions.html.haml
@@ -3,4 +3,4 @@
.form-group
= label_tag _('User restrictions')
= render_if_exists 'admin/application_settings/updating_name_disabled_for_users', form: form
- = form.gitlab_ui_checkbox_component :can_create_group, _("Allow users to create top-level groups")
+ = form.gitlab_ui_checkbox_component :can_create_group, _("Allow new users to create top-level groups")
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 b69b2f74d0d..85bee72e863 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -64,4 +64,7 @@
= render_if_exists 'admin/application_settings/globally_allowed_ips', form: f
+ -# This is added for Jihu edition in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/1112
+ = render_if_exists 'admin/application_settings/disable_download_button', f: f
+
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml
index a3bd8b52148..5f51e91436c 100644
--- a/app/views/admin/application_settings/appearances/_form.html.haml
+++ b/app/views/admin/application_settings/appearances/_form.html.haml
@@ -101,7 +101,7 @@
= parsed_with_gfm
.gl-mt-3.gl-mb-3
- = f.submit _('Update appearance settings'), class: 'btn gl-button btn-confirm'
+ = f.submit _('Update appearance settings'), pajamas_button: true
- if @appearance.persisted? || @appearance.updated_at
.mt-4
- if @appearance.persisted?
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index ec5d1ef4a34..6d8428d1aa6 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -119,8 +119,9 @@
= render_if_exists 'admin/application_settings/feishu_integration'
= render 'admin/application_settings/third_party_offers'
= render 'admin/application_settings/snowplow'
+= render_if_exists 'admin/application_settings/product_analytics'
= render 'admin/application_settings/error_tracking' if Feature.enabled?(:gitlab_error_tracking)
= render 'admin/application_settings/eks'
= render 'admin/application_settings/floc'
= render_if_exists 'admin/application_settings/add_license'
-= render 'admin/application_settings/jira_connect_application_key' if Feature.enabled?(:jira_connect_oauth, current_user)
+= render 'admin/application_settings/jira_connect' if Feature.enabled?(:jira_connect_oauth_self_managed_setting, current_user)
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 06bb9df84c4..82b627e1805 100644
--- a/app/views/admin/application_settings/service_usage_data.html.haml
+++ b/app/views/admin/application_settings/service_usage_data.html.haml
@@ -24,7 +24,7 @@
= c.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('administration/troubleshooting/gitlab_rails_cheat_sheet', anchor: 'generate-service-ping')
+ - generate_manually_link_url = help_page_path('development/service_ping/troubleshooting', anchor: 'generate-service-ping')
- generate_manually_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: generate_manually_link_url }
= html_escape(s_('%{enable_service_ping_link_start}Enable%{link_end} or %{generate_manually_link_start}generate%{link_end} Service Ping to preview and download service usage data payload.')) % { enable_service_ping_link_start: enable_service_ping_link, generate_manually_link_start: generate_manually_link, link_end: '</a>'.html_safe }
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index ccea1714973..886402139e9 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -122,7 +122,7 @@
= s_('AdminArea|Components')
- if show_version_check?
.float-right
- .js-gitlab-version-check{ data: { "size": "lg" } }
+ .js-gitlab-version-check-badge{ data: { "size": "lg", "actionable": "true" } }
= link_to(sprite_icon('question'), "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
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index 6d370919460..c8b0704c35d 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -7,8 +7,9 @@
%h1.page-title.gl-font-size-h-display
= _('Group: %{group_name}') % { group_name: @group.full_name }
- = link_to admin_group_edit_path(@group), class: "btn btn-default gl-button float-right", data: { qa_selector: 'edit_group_link' } do
- = sprite_icon('pencil', css_class: 'gl-icon gl-mr-2')
+ = render Pajamas::ButtonComponent.new(href: admin_group_edit_path(@group),
+ button_options: { class: 'gl-float-right', data: { qa_selector: 'edit_group_link' }},
+ icon: 'pencil') do
= _('Edit')
%hr
.row
diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml
index 47a761e608f..6809f147ef8 100644
--- a/app/views/admin/users/_form.html.haml
+++ b/app/views/admin/users/_form.html.haml
@@ -75,8 +75,8 @@
%div
- if @user.new_record?
- = f.submit _('Create user'), class: "btn gl-button btn-confirm"
+ = f.submit _('Create user'), pajamas_button: true
= link_to _('Cancel'), admin_users_path, class: "gl-button btn btn-default btn-cancel"
- else
- = f.submit _('Save changes'), class: "btn gl-button btn-confirm"
+ = f.submit _('Save changes'), pajamas_button: true
= link_to _('Cancel'), admin_user_path(@user), class: "gl-button btn btn-default btn-cancel"
diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml
index 0ceff211806..1fa7c9c8651 100644
--- a/app/views/admin/users/_head.html.haml
+++ b/app/views/admin/users/_head.html.haml
@@ -30,9 +30,11 @@
.gl-p-2
#js-admin-user-actions{ data: admin_user_actions_data_attributes(@user) }
- if @user != current_user
- - if impersonation_enabled? && @user.can?(:log_in)
+ - if impersonation_enabled?
.gl-p-2
- = link_to _('Impersonate'), impersonate_admin_user_path(@user), method: :post, class: "btn btn-default gl-button", data: { qa_selector: 'impersonate_user_link' }
+ %span.btn-group{ class: !@can_impersonate ? 'has-tooltip' : nil, title: @impersonation_error_text }
+ = render Pajamas::ButtonComponent.new(disabled: !@can_impersonate, method: :post, href: impersonate_admin_user_path(@user), button_options: { data: { qa_selector: 'impersonate_user_link', testid: 'impersonate_user_link' } }) do
+ = _('Impersonate')
- if can_force_email_confirmation?(@user)
.gl-p-2
= render Pajamas::ButtonComponent.new(variant: :default, button_options: { class: 'js-confirm-modal-button', data: confirm_user_data(@user) }) do
diff --git a/app/views/admin/users/_projects.html.haml b/app/views/admin/users/_projects.html.haml
index 3ccf3ef4f2a..2f77e83ac49 100644
--- a/app/views/admin/users/_projects.html.haml
+++ b/app/views/admin/users/_projects.html.haml
@@ -5,7 +5,7 @@
- c.body do
= render 'shared/projects/list',
projects: contributed_projects.sort_by(&:star_count).reverse,
- projects_limit: 5, stars: true, avatar: false
+ 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|
@@ -14,4 +14,4 @@
- c.body do
= render 'shared/projects/list',
projects: projects.sort_by(&:star_count).reverse,
- projects_limit: 10, stars: true, avatar: false
+ projects_limit: 10, stars: true, avatar: false, compact_mode: true
diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml
index 8c77cb394ba..6d85ff50fbe 100644
--- a/app/views/admin/users/_users.html.haml
+++ b/app/views/admin/users/_users.html.haml
@@ -54,7 +54,7 @@
= hidden_field_tag "filter", h(params[:filter])
.search-holder
.search-field-holder.gl-mb-4
- = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false, data: { qa_selector: 'user_search_field' }
+ = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email, or username'), class: 'form-control search-text-input js-search-input', spellcheck: false, data: { qa_selector: 'user_search_field' }
- if @sort.present?
= hidden_field_tag :sort, @sort
= sprite_icon('search', css_class: 'search-icon')
diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml
index 3952a450c4a..5062599c261 100644
--- a/app/views/award_emoji/_awards_block.html.haml
+++ b/app/views/award_emoji/_awards_block.html.haml
@@ -1,7 +1,7 @@
- api_awards_path = local_assigns.fetch(:api_awards_path, nil)
- if api_awards_path
- .gl-display-flex.gl-flex-wrap.gl-justify-content-space-between.gl-py-3
+ .gl-display-flex.gl-flex-wrap.gl-justify-content-space-between.gl-pt-3
#js-vue-awards-block{ data: { path: api_awards_path, can_award_emoji: can?(current_user, :award_emoji, awardable).to_s } }
= yield
- else
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index 9ca11b35064..08865abbe86 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -10,7 +10,7 @@
- is_group = !@group.nil?
- is_project = !@project.nil?
-#js-ci-project-variables{ data: { endpoint: save_endpoint,
+#js-ci-variables{ data: { endpoint: save_endpoint,
is_project: is_project.to_s,
project_id: @project&.id || '',
project_full_path: @project&.full_path || '',
diff --git a/app/views/ci/variables/_url_query_variable_row.html.haml b/app/views/ci/variables/_url_query_variable_row.html.haml
deleted file mode 100644
index 77bcacdb94b..00000000000
--- a/app/views/ci/variables/_url_query_variable_row.html.haml
+++ /dev/null
@@ -1,28 +0,0 @@
-- form_field = local_assigns.fetch(:form_field, nil)
-- variable = local_assigns.fetch(:variable, nil)
-
-- key = variable[0]
-- value = variable[1]
-- variable_type = variable[2] || "env_var"
-
-- destroy_input_name = "#{form_field}[variables_attributes][][_destroy]"
-- variable_type_input_name = "#{form_field}[variables_attributes][][variable_type]"
-- key_input_name = "#{form_field}[variables_attributes][][key]"
-- value_input_name = "#{form_field}[variables_attributes][][secret_value]"
-
-%li.js-row.ci-variable-row
- .ci-variable-row-body.border-bottom
- %input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name }
- %select.js-ci-variable-input-variable-type.ci-variable-body-item.form-control.select-control.custom-select.table-section.section-15{ name: variable_type_input_name }
- = options_for_select(ci_variable_type_options, variable_type)
- %input.js-ci-variable-input-key.ci-variable-body-item.form-control.table-section.section-15{ type: "text",
- name: key_input_name,
- value: key,
- placeholder: s_('CiVariables|Input variable key') }
- .ci-variable-body-item.gl-show-field-errors.table-section.section-15.border-top-0.p-0
- %textarea.js-ci-variable-input-value.js-secret-value.form-control{ rows: 1,
- name: value_input_name,
- placeholder: s_('CiVariables|Input variable value') }
- = value
- %button.gl-button.btn.btn-default.btn-icon.btn-item-remove.js-row-remove-button.ci-variable-row-remove-button.table-section{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') }
- = sprite_icon('close')
diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml
index 813c1cdbfe4..1c82b30ed8d 100644
--- a/app/views/dashboard/_groups_head.html.haml
+++ b/app/views/dashboard/_groups_head.html.haml
@@ -8,7 +8,7 @@
.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
+ = gl_tab_link_to _("Explore public groups"), explore_groups_path, data: { qa_selector: "public_groups_tab" }
.nav-controls
= render 'shared/groups/search_form'
= render 'shared/groups/dropdown'
diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml
index 95e772f324b..79f6bfc866a 100644
--- a/app/views/dashboard/issues.html.haml
+++ b/app/views/dashboard/issues.html.haml
@@ -1,6 +1,7 @@
- @hide_top_links = true
- page_title _("Issues")
- @breadcrumb_link = issues_dashboard_path(assignee_username: current_user.username)
+- add_page_specific_style 'page_bundles/dashboard'
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{current_user.name} issues")
@@ -13,14 +14,20 @@
.page-title-controls
= render 'shared/new_project_item_select', path: 'issues/new', label: _("issue"), with_feature_enabled: 'issues', type: :issues
-.top-area
- = render 'shared/issuable/nav', type: :issues, display_count: !@no_filters_set
- .nav-controls
- = render 'shared/issuable/feed_buttons'
+- if ::Feature.enabled?(:vue_issues_dashboard)
+ .js-issues-dashboard{ data: { calendar_path: url_for(safe_params.merge(calendar_url_options)),
+ empty_state_svg_path: image_path('illustrations/issue-dashboard_results-without-filter.svg'),
+ is_signed_in: current_user.present?.to_s,
+ rss_path: url_for(safe_params.merge(rss_url_options)) } }
+- 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
+ = render 'shared/issuable/search_bar', type: :issues
-- if current_user && @no_filters_set
- = render 'shared/dashboard/no_filter_selected'
-- else
- = render 'shared/issues'
+ - if current_user && @no_filters_set
+ = render 'shared/dashboard/no_filter_selected'
+ - else
+ = render 'shared/issues'
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index b4668b1e52a..47bc8f5c95b 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -27,8 +27,7 @@
= todo_target_title(todo)
%span.title-item.todo-project.todo-label
- at
- = todo_parent_path(todo)
+ = s_('Todo|at %{todo_parent_path}').html_safe % { todo_parent_path: todo_parent_path(todo) }
- if todo.self_assigned?
%span.title-item.action-name
diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml
index ef19ac33a15..01f9595f35c 100644
--- a/app/views/devise/confirmations/almost_there.haml
+++ b/app/views/devise/confirmations/almost_there.haml
@@ -1,4 +1,4 @@
-- user_email = "(#{params[:email]})" if params[:email].present?
+- 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
- content_for :page_specific_javascripts do
diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml
index e0e0b82b596..a4edf165a89 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -2,7 +2,6 @@
- content_for :page_specific_javascripts do
= render "layouts/google_tag_manager_head"
= render "layouts/one_trust"
- = render "layouts/bizible"
= render "layouts/google_tag_manager_body"
#signin-container
@@ -17,7 +16,7 @@
%div
= _('No authentication methods configured.')
- - if Feature.enabled?(:restyle_login_page, @project)
+ - if Feature.enabled?(:restyle_login_page, @project) && Gitlab::CurrentSettings.current_application_settings.terms
%p.gl-px-5
= html_escape(s_("SignUp|By signing in you accept the %{link_start}Terms of Use and acknowledge the Privacy Policy and Cookie Policy%{link_end}.")) % { link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe,
link_end: '</a>'.html_safe }
diff --git a/app/views/doorkeeper/authorizations/forbidden.html.haml b/app/views/doorkeeper/authorizations/forbidden.html.haml
new file mode 100644
index 00000000000..9cad5ce62d8
--- /dev/null
+++ b/app/views/doorkeeper/authorizations/forbidden.html.haml
@@ -0,0 +1,5 @@
+%h1.page-title.gl-font-size-h-display= _("Forbidden")
+%main{ :role => "main" }
+ %p
+ = sprite_icon('warning-solid')
+ = (_("Administrators are not permitted to connect applications with these scopes: %{code_open}api%{code_close}, %{code_open}read_api%{code_close}, %{code_open}read_repository%{code_close}, %{code_open}write_repository%{code_close}, %{code_open}write_registry%{code_close}, %{code_open}read_registry%{code_close}, and %{code_open}sudo%{code_close}. To permit this, change the %{code_open}disable_admin_oauth_scopes%{code_close} setting using the API.") % { code_open: '<code>', code_close: '</code>' }).html_safe
diff --git a/app/views/errors/request_conflict.html.haml b/app/views/errors/request_conflict.html.haml
index 2f5abaca72f..040aa880e1c 100644
--- a/app/views/errors/request_conflict.html.haml
+++ b/app/views/errors/request_conflict.html.haml
@@ -13,6 +13,6 @@
%p
= s_('409|Please contact your GitLab administrator if you think this is a mistake.')
.action-container.js-go-back{ hidden: true }
- %button{ type: 'button', class: 'gl-button btn btn-primary' }
+ = render Pajamas::ButtonComponent.new(variant: :confirm) do
= _('Go Back')
= render "errors/footer"
diff --git a/app/views/groups/_archived_projects.html.haml b/app/views/groups/_archived_projects.html.haml
deleted file mode 100644
index 21107cc22a1..00000000000
--- a/app/views/groups/_archived_projects.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-#js-groups-archived-tree
- .empty-state.text-center.hidden
- %p= _("There are no archived projects yet")
-
- %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
- .js-groups-list-holder
- = gl_loading_icon(size: 'md', css_class: 'gl-mt-6')
diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml
deleted file mode 100644
index 6063d160fab..00000000000
--- a/app/views/groups/_shared_projects.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-#js-groups-shared-tree
- .empty-state.text-center.hidden
- %p= _("There are no projects shared with this group yet")
-
- %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
- .js-groups-list-holder{ data: { current_group_visibility: group.visibility } }
- = gl_loading_icon
diff --git a/app/views/groups/_subgroups_and_projects.html.haml b/app/views/groups/_subgroups_and_projects.html.haml
deleted file mode 100644
index dc749af3c0c..00000000000
--- a/app/views/groups/_subgroups_and_projects.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-#js-groups-subgroups_and_projects-tree
- %section{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
- .js-groups-list-holder{ data: subgroups_and_projects_list_app_data(group) }
- = gl_loading_icon(size: 'md', css_class: 'gl-mt-6')
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index d9fef8940eb..4da70c8bf5d 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -1,8 +1,6 @@
- add_page_specific_style 'page_bundles/members'
- page_title _('Group members')
-= render_if_exists 'shared/free_user_cap_alert', source: @group
-
.row.gl-mt-3
.col-lg-12
.gl-display-flex.gl-flex-wrap
diff --git a/app/views/groups/observability/index.html.haml b/app/views/groups/observability/index.html.haml
deleted file mode 100644
index 582651c329b..00000000000
--- a/app/views/groups/observability/index.html.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-- page_title _("Observability")
-%iframe{ id: 'observability-ui-iframe', src: @observability_iframe_src, frameborder: 0, width: "100%", height: "100%" }
diff --git a/app/views/groups/observability/observability.html.haml b/app/views/groups/observability/observability.html.haml
new file mode 100644
index 00000000000..834fa0e027c
--- /dev/null
+++ b/app/views/groups/observability/observability.html.haml
@@ -0,0 +1,3 @@
+- page_title observability_page_title
+
+#js-observability-app{ data: { observability_iframe_src: observability_iframe_src(@group) } }
diff --git a/app/views/groups/settings/_remove.html.haml b/app/views/groups/settings/_remove.html.haml
index 8571b93364b..a37a0f8053b 100644
--- a/app/views/groups/settings/_remove.html.haml
+++ b/app/views/groups/settings/_remove.html.haml
@@ -1,6 +1,6 @@
- remove_form_id = local_assigns.fetch(:remove_form_id, nil)
- if group.adjourned_deletion?
- = render_if_exists 'groups/settings/adjourned_deletion', group: group, remove_form_id: remove_form_id
+ = render_if_exists 'groups/settings/delayed_deletion', group: group, remove_form_id: remove_form_id
- else
= render 'groups/settings/permanent_deletion', group: group, remove_form_id: remove_form_id
diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml
index 7fe5a7a665b..e01d703206c 100644
--- a/app/views/groups/settings/_transfer.html.haml
+++ b/app/views/groups/settings/_transfer.html.haml
@@ -1,5 +1,5 @@
- form_id = "transfer-group-form"
-- initial_data = { button_text: s_('GroupSettings|Transfer group'), group_name: @group.name, target_form_id: form_id, parent_groups: parent_group_options(group), is_paid_group: group.paid?.to_s }
+- initial_data = { button_text: s_('GroupSettings|Transfer group'), group_name: @group.name, group_id: @group.id, target_form_id: form_id, is_paid_group: group.paid?.to_s }
.sub-section
%h4.warning-title= s_('GroupSettings|Transfer group')
diff --git a/app/views/groups/settings/access_tokens/index.html.haml b/app/views/groups/settings/access_tokens/index.html.haml
index 5e3d814687e..309633471a5 100644
--- a/app/views/groups/settings/access_tokens/index.html.haml
+++ b/app/views/groups/settings/access_tokens/index.html.haml
@@ -39,6 +39,5 @@
prefix: :resource_access_token,
help_path: help_page_path('user/group/settings/group_access_tokens', anchor: 'scopes-for-a-group-access-token')
- #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_resource_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true
+ #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true
} }
-
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 012a31c1ecf..72b7bec1b92 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -4,7 +4,6 @@
- add_page_specific_style 'page_bundles/group'
= render_if_exists 'shared/qrtly_reconciliation_alert', group: @group
-= render_if_exists 'shared/free_user_cap_alert', source: @group
- if show_invite_banner?(@group)
= content_for :group_invite_members_banner do
@@ -30,36 +29,4 @@
= render_if_exists 'groups/group_activity_analytics', group: @group
-- if Feature.enabled?(:group_overview_tabs_vue, @group)
- #js-group-overview-tabs{ data: group_overview_tabs_app_data(@group) }
-- else
- .groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } }
- .top-area.group-nav-container.justify-content-between
- .scrolling-tabs-container.inner-page-scroll-tabs
- .fade-left= sprite_icon('chevron-lg-left', size: 12)
- .fade-right= sprite_icon('chevron-lg-right', size: 12)
- -# `item_active` is set to `false` as the active state is set by `app/assets/javascripts/pages/groups/shared/group_details.js`
- -# TODO: Replace this approach in https://gitlab.com/gitlab-org/gitlab/-/issues/23466
- = gl_tabs_nav({ class: 'nav-links scrolling-tabs gl-display-flex gl-flex-grow-1 gl-flex-nowrap gl-border-0' }) do
- = gl_tab_link_to group_path, item_active: false, tab_class: 'js-subgroups_and_projects-tab', data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab' } do
- = _("Subgroups and projects")
- = gl_tab_link_to group_shared_path, item_active: false, tab_class: 'js-shared-tab', data: { target: 'div#shared', action: 'shared', toggle: 'tab' } do
- = _("Shared projects")
- = gl_tab_link_to group_archived_path, item_active: false, tab_class: 'js-archived-tab', data: { target: 'div#archived', action: 'archived', toggle: 'tab' } do
- = _("Archived projects")
-
- .nav-controls.d-block.d-md-flex
- .group-search
- = render "shared/groups/search_form"
-
- = render "shared/groups/dropdown", options_hash: subgroups_sort_options_hash
-
- .tab-content
- #subgroups_and_projects.tab-pane
- = render "subgroups_and_projects", group: @group
-
- #shared.tab-pane
- = render "shared_projects", group: @group
-
- #archived.tab-pane
- = render "archived_projects", group: @group
+#js-group-overview-tabs{ data: group_overview_tabs_app_data(@group) }
diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml
index eaa58580454..8c74aac5ef5 100644
--- a/app/views/help/index.html.haml
+++ b/app/views/help/index.html.haml
@@ -11,7 +11,7 @@
%span= link_to_version
- if show_version_check?
%span.gl-mt-5.gl-mb-3.gl-ml-3
- .js-gitlab-version-check{ data: { "size": "lg" } }
+ .js-gitlab-version-check-badge{ data: { "size": "lg", "actionable": "true" } }
%hr
- unless Gitlab::CurrentSettings.help_page_hide_commercial_content?
diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml
index 9ea52a8f82f..e92db09aaf1 100644
--- a/app/views/import/_githubish_status.html.haml
+++ b/app/views/import/_githubish_status.html.haml
@@ -4,7 +4,7 @@
- filterable = local_assigns.fetch(:filterable, true)
- paginatable = local_assigns.fetch(:paginatable, false)
- default_namespace_path = (local_assigns[:default_namespace] || current_user.namespace).full_path
-- provider_title = Gitlab::ImportSources.title(provider)
+- provider_title = Gitlab::ImportSources.title(local_assigns.fetch(:provider))
- optional_stages = local_assigns.fetch(:optional_stages, [])
- header_title _("New project"), new_project_path
diff --git a/app/views/layouts/_img_loader.html.haml b/app/views/layouts/_img_loader.html.haml
index f6d7d163e6f..979ebeb0a02 100644
--- a/app/views/layouts/_img_loader.html.haml
+++ b/app/views/layouts/_img_loader.html.haml
@@ -12,6 +12,7 @@
img.src = imgUrl;
img.removeAttribute('data-src');
img.classList.remove('lazy');
- img.classList.add('js-lazy-loaded', 'qa-js-lazy-loaded');
+ img.classList.add('js-lazy-loaded');
+ img.dataset.qa_selector = 'js_lazy_loaded_content';
});
}
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 014e26c7613..d668399b408 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -24,7 +24,7 @@
= yield :group_invite_members_banner
- unless @hide_breadcrumbs
= render "layouts/nav/breadcrumbs"
- %div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
+ %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'
= yield :after_flash_content
diff --git a/app/views/layouts/component_preview.html.haml b/app/views/layouts/component_preview.html.haml
index ec12395a5d4..a1b1304f994 100644
--- a/app/views/layouts/component_preview.html.haml
+++ b/app/views/layouts/component_preview.html.haml
@@ -1,5 +1,14 @@
%head
- = stylesheet_link_tag "application"
- = stylesheet_link_tag "application_utilities"
-%body{ style: "background-color: #{params.dig(:lookbook, :display, :bg_color) || 'white'}" }
- .container.gl-mt-6= yield
+ - 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
+ - if params[:lookbook][:display][:bg_dark]
+ .bg-dark.rounded.shadow.p-4
+ = yield
+ - else
+ = yield
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index 97c2b8bb7e3..95934f43a51 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -16,4 +16,6 @@
:plain
window.uploads_path = "#{group_uploads_path(@group)}";
+= dispensable_render_if_exists "shared/free_user_cap_alert", source: @group
+
= render template: base_layout || "layouts/application"
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index b74dfd4d3a1..42ffd155647 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -19,16 +19,9 @@
.gl-display-none.gl-sm-display-block
= render "layouts/nav/top_nav"
- else
- - experiment(:logged_out_marketing_header, actor: nil) do |e|
- - e.candidate do
- = render 'layouts/header/marketing_links'
- - e.try(:trial_focused) do
- = render 'layouts/header/marketing_links'
- - e.control do
- .gl-display-none.gl-sm-display-block
- = render "layouts/nav/top_nav"
+ = render 'layouts/header/marketing_links'
- - if top_nav_show_search && Feature.enabled?(:new_navbar_layout)
+ - if top_nav_show_search
.navbar-collapse.gl-transition-medium.collapse.gl-mr-auto.global-search-container.hide-when-top-nav-responsive-open
- search_menu_item = top_nav_search_menu_item_attrs
%ul.nav.navbar-nav.gl-w-full.gl-align-items-center
@@ -42,21 +35,10 @@
= 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'} do
= sprite_icon(search_menu_item.fetch(:icon))
- .navbar-collapse.gl-transition-medium.collapse{ class: ('global-search-container' unless Feature.enabled?(:new_navbar_layout)) }
+ .navbar-collapse.gl-transition-medium.collapse
%ul.nav.navbar-nav.gl-w-full.gl-align-items-center.gl-justify-content-end
- if current_user
= render 'layouts/header/new_dropdown', class: 'gl-display-none gl-sm-display-block gl-white-space-nowrap gl-text-right'
- - if top_nav_show_search && Feature.disabled?(:new_navbar_layout)
- - search_menu_item = top_nav_search_menu_item_attrs
- %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'
- %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'} do
- = sprite_icon(search_menu_item.fetch(:icon))
- if header_link?(:issues)
= nav_link(path: 'dashboard#issues', html_options: { class: "user-counter" }) do
= link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues js-prefetch-document', aria: { label: _('Issues') },
@@ -67,7 +49,7 @@
container: 'body' } do
= sprite_icon('issues')
- issues_count = assigned_issuables_count(:issues)
- = gl_badge_tag({ size: :sm, variant: :success }, { class: "gl-ml-n2 #{(' gl-display-none' if issues_count == 0)}", "aria-label": n_("%d assigned issue", "%d assigned issues", issues_count) % issues_count }) do
+ = gl_badge_tag({ size: :sm, variant: :success }, { class: "gl-ml-n2 #{'gl-display-none' if issues_count == 0}", "aria-label": n_("%d assigned issue", "%d assigned issues", issues_count) % issues_count }) do
= number_with_delimiter(issues_count)
- if header_link?(:merge_requests)
= nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter dropdown" }) do
@@ -81,7 +63,7 @@
track_property: 'navigation',
container: 'body' } do
= sprite_icon('git-merge')
- = gl_badge_tag({ size: :sm, variant: :warning }, { class: "js-merge-requests-count gl-ml-n2#{(' gl-display-none' if user_merge_requests_counts[:total] == 0)}", "aria-label": n_("%d merge request", "%d merge requests", user_merge_requests_counts[:total]) % user_merge_requests_counts[:total] }) do
+ = gl_badge_tag({ size: :sm, variant: :warning }, { class: "js-merge-requests-count gl-ml-n2 #{'gl-display-none' if user_merge_requests_counts[:total] == 0}", "aria-label": n_("%d merge request", "%d merge requests", user_merge_requests_counts[:total]) % user_merge_requests_counts[:total] }) do
= number_with_delimiter(user_merge_requests_counts[:total])
= sprite_icon('chevron-down', css_class: 'caret-down gl-mx-0!')
.dropdown-menu.dropdown-menu-right
@@ -94,7 +76,7 @@
= gl_badge_tag({ variant: :neutral, size: :sm }, { class: "js-assigned-mr-count gl-ml-auto" }) do
= user_merge_requests_counts[:assigned]
%li
- = link_to reviewer_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do
+ = link_to reviewer_mrs_dashboard_path, class: 'dashboard-shortcuts-review_requests gl-display-flex! gl-align-items-center js-prefetch-document' do
= _('Review requests for you')
= gl_badge_tag({ variant: :neutral, size: :sm }, { class: "js-reviewer-mr-count gl-ml-auto" }) do
= user_merge_requests_counts[:review_requested]
@@ -109,7 +91,7 @@
= sprite_icon('todo-done')
-# The todos' counter badge's visibility is being toggled by adding or removing the .hidden class in Js.
-# We'll eventually migrate to .gl-display-none: https://gitlab.com/gitlab-org/gitlab/-/issues/351792.
- = gl_badge_tag({ size: :sm, variant: :info }, { class: "js-todos-count gl-ml-n2#{(' hidden' if todos_pending_count == 0)}", "aria-label": _("Todos count") }) do
+ = 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{ data: { track_action: 'click_question_mark_link', track_label: 'main_navigation', track_property: 'navigation', track_experiment: 'cross_stage_fdm' } }
= link_to help_path, class: 'header-help-dropdown-toggle gl-relative', data: { toggle: "dropdown" } do
@@ -121,14 +103,8 @@
.dropdown-menu.dropdown-menu-right
= render 'layouts/header/help_dropdown'
- unless current_user
- - experiment(:logged_out_marketing_header, actor: nil) do |e|
- - e.candidate do
- %li.nav-item.gl-display-none.gl-sm-display-block
- = render "layouts/nav/top_nav"
- - e.try(:trial_focused) do
- %li.nav-item.gl-display-none.gl-sm-display-block
- = render "layouts/nav/top_nav"
- - e.control {}
+ %li.nav-item.gl-display-none.gl-sm-display-block
+ = render "layouts/nav/top_nav"
- if header_link?(:user_dropdown)
%li.nav-item.header-user.js-nav-user-dropdown.dropdown{ data: { track_label: "profile_dropdown", track_action: "click_dropdown", track_value: "", qa_selector: 'user_menu', testid: 'user-menu' }, class: ('mr-0' if has_impersonation_link) }
= link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do
@@ -142,23 +118,11 @@
= link_to admin_impersonation_path, class: 'nav-link impersonation-btn', method: :delete, title: _('Stop impersonation'), aria: { label: _('Stop impersonation') }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body', qa_selector: 'stop_impersonation_link' } do
= sprite_icon('incognito', size: 18)
- if header_link?(:sign_in)
- - experiment(:logged_out_marketing_header, actor: nil) do |e|
- - e.candidate do
- %li.nav-item.gl-display-none.gl-sm-display-block
- = link_to _('Sign up now'), new_user_registration_path, class: 'gl-button btn btn-default btn-sign-in', data: { track_action: 'click_button', track_experiment: e.name, track_label: 'sign_up_now' }
- %li.nav-item.gl-display-none.gl-sm-display-block
- = link_to _('Login'), new_session_path(:user, redirect_to_referer: 'yes')
- = render 'layouts/header/sign_in_register_button', class: 'gl-sm-display-none'
- - e.try(:trial_focused) do
- %li.nav-item.gl-display-none.gl-sm-display-block
- = link_to _('Get a free trial'), 'https://about.gitlab.com/free-trial/', class: 'gl-button btn btn-default btn-sign-in', data: { track_action: 'click_button', track_experiment: e.name, track_label: 'get_a_free_trial' }
- %li.nav-item.gl-display-none.gl-sm-display-block
- = link_to _('Sign up'), new_user_registration_path, data: { track_action: 'click_button', track_experiment: e.name, track_label: 'sign_up' }
- %li.nav-item.gl-display-none.gl-sm-display-block
- = link_to _('Login'), new_session_path(:user, redirect_to_referer: 'yes')
- = render 'layouts/header/sign_in_register_button', class: 'gl-sm-display-none'
- - e.control do
- = render 'layouts/header/sign_in_register_button'
+ %li.nav-item.gl-display-none.gl-sm-display-block
+ = link_to _('Sign up now'), new_user_registration_path, class: 'gl-button btn btn-default btn-sign-in'
+ %li.nav-item.gl-display-none.gl-sm-display-block
+ = link_to _('Login'), new_session_path(:user, redirect_to_referer: 'yes')
+ = render 'layouts/header/sign_in_register_button', class: 'gl-sm-display-none'
%button.navbar-toggler.d-block.d-sm-none{ type: 'button', class: 'gl-border-none!', data: { testid: 'top-nav-responsive-toggle', qa_selector: 'mobile_navbar_button' } }
%span.sr-only= _('Toggle navigation')
diff --git a/app/views/layouts/header/_gitlab_version.html.haml b/app/views/layouts/header/_gitlab_version.html.haml
index fae6926a687..2315caa5fe8 100644
--- a/app/views/layouts/header/_gitlab_version.html.haml
+++ b/app/views/layouts/header/_gitlab_version.html.haml
@@ -1,6 +1,15 @@
- return unless show_version_check?
-%a{ class: 'gl-display-flex! gl-flex-direction-column gl-px-4! gl-py-3! gl-line-height-24!', href: help_page_path('update/index'), 'data-testid': 'gitlab-version-container' }
+%a{
+ class: 'gl-display-flex! gl-flex-direction-column gl-px-4! gl-py-3! gl-line-height-24!',
+ href: help_page_path('update/index'),
+ data: {
+ testid: 'gitlab-version-container',
+ track_action: 'click_link',
+ track_label: 'version_help_dropdown',
+ track_property: "#{Gitlab.version_info.major}.#{Gitlab.version_info.minor}"
+ }
+ }
%span
= s_("VersionCheck|Your GitLab Version")
= emoji_icon('rocket')
@@ -8,4 +17,4 @@
%span.gl-font-sm.gl-text-gray-500
#{Gitlab.version_info.major}.#{Gitlab.version_info.minor}
%span.gl-ml-2
- .js-gitlab-version-check{ data: { "size": "sm" } }
+ .js-gitlab-version-check-badge{ data: { "size": "sm" } }
diff --git a/app/views/layouts/header/_new_dropdown.html.haml b/app/views/layouts/header/_new_dropdown.html.haml
index 9801b0cc055..f39fb53032d 100644
--- a/app/views/layouts/header/_new_dropdown.html.haml
+++ b/app/views/layouts/header/_new_dropdown.html.haml
@@ -7,7 +7,7 @@
- return if menu_sections.empty?
%li.header-new.gl-flex-grow-1.gl-flex-shrink-1.dropdown{ class: top_class, data: { track_label: "new_dropdown", track_action: "click_dropdown" } }
- = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip gl-display-inline-block!", id: "js-onboarding-new-project-link", title: title, ref: 'tooltip', aria: { label: title }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static', qa_selector: 'new_menu_toggle' } do
+ = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip gl-display-flex", id: "js-onboarding-new-project-link", title: title, ref: 'tooltip', aria: { label: title }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static', qa_selector: 'new_menu_toggle' } do
= sprite_icon('plus-square')
= sprite_icon('chevron-down', css_class: 'caret-down')
.dropdown-menu.dropdown-menu-right.dropdown-extended-height
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index 75d5e40011c..6ad6696b313 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -19,5 +19,6 @@
window.uploads_path = "#{project_uploads_path(project)}";
= dispensable_render_if_exists "shared/web_hooks/web_hook_disabled_alert"
+= dispensable_render_if_exists "projects/free_user_cap_alert", project: @project
= render template: "layouts/application"
diff --git a/app/views/notify/pipeline_failed_email.html.haml b/app/views/notify/pipeline_failed_email.html.haml
index 4e8d8a20ef1..5d4d2c0fcd8 100644
--- a/app/views/notify/pipeline_failed_email.html.haml
+++ b/app/views/notify/pipeline_failed_email.html.haml
@@ -6,7 +6,7 @@
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;padding-right:5px;line-height:1;" }
%img{ alt: "✖", height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-x-red-inverted.gif'), style: "display:block;", width: "13" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;" }
- Pipeline ##{@pipeline.id} has failed!
+ = s_('Notify|Pipeline #%{pipeline_id} has failed!') % { pipeline_id: @pipeline.id }
%tr.spacer
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" }
&nbsp;
@@ -15,7 +15,8 @@
%table.table-info{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;" }
%tbody
%tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" } Project
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" }
+ = _('Project')
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;" }
- namespace_name = @project.group ? @project.group.name : @project.namespace.owner.name
- namespace_url = @project.group ? group_url(@project.group) : user_url(@project.namespace.owner)
@@ -25,7 +26,8 @@
%a.muted{ href: project_url(@project), style: "color:#333333;text-decoration:none;" }
= @project.name
%tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Branch
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }
+ = _('Branch')
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
%table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" }
%tbody
@@ -36,7 +38,8 @@
%a.muted{ href: commits_url(@pipeline), style: "color:#333333;text-decoration:none;" }
= @pipeline.source_ref
%tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Commit
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }
+ = _('Commit')
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:400;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
%table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" }
%tbody
@@ -54,7 +57,8 @@
= @pipeline.git_commit_message.truncate(50)
- commit = @pipeline.commit
%tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Commit Author
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }
+ = s_('Notify|Commit Author')
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
%table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" }
%tbody
@@ -93,11 +97,10 @@
%table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;margin:0 auto;" }
%tbody
%tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" }
- Pipeline
- %a{ href: pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;" }
- = "\##{@pipeline.id}"
- triggered by
+ - common_style = "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;"
+ - pipeline_link = link_to "##{@pipeline.id}", pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;"
+ %td{ style: "#{common_style}" }
+ = s_('Notify|Pipeline %{pipeline_link} triggered by').html_safe % { pipeline_link: pipeline_link }
- if @pipeline.user
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;padding-left:5px", width: "24" }
%img.avatar{ height: "24", src: avatar_icon_for_user(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
diff --git a/app/views/notify/unknown_sign_in_email.html.haml b/app/views/notify/unknown_sign_in_email.html.haml
index b1c79274e26..64bf4e7b4e8 100644
--- a/app/views/notify/unknown_sign_in_email.html.haml
+++ b/app/views/notify/unknown_sign_in_email.html.haml
@@ -9,7 +9,7 @@
%tr
%td{ style: "#{default_font}vertical-align:middle;color:#ffffff;text-align:center;" }
%span
- = _("Your %{host} account was signed in to from a new location") % { host: Gitlab.config.gitlab.host }
+ = _("Someone signed in to your %{host} account from a new location") % { host: Gitlab.config.gitlab.host }
%tr.spacer
%td{ style: spacer_style }
&nbsp;
diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml
index b37a0d9cc1a..e6d91543585 100644
--- a/app/views/profiles/keys/_form.html.haml
+++ b/app/views/profiles/keys/_form.html.haml
@@ -5,12 +5,12 @@
.form-group
= f.label :key, s_('Profiles|Key'), class: 'label-bold'
- = f.text_area :key, class: "form-control gl-form-input js-add-ssh-key-validation-input qa-key-public-key-field", rows: 8, required: true, data: { supported_algorithms: Gitlab::SSHPublicKey.supported_algorithms }
+ = f.text_area :key, class: "form-control gl-form-input js-add-ssh-key-validation-input", rows: 8, required: true, data: { supported_algorithms: Gitlab::SSHPublicKey.supported_algorithms, qa_selector: 'key_public_key_field' }
%p.form-text.text-muted= s_('Profiles|Begins with %{ssh_key_algorithms}.') % { ssh_key_algorithms: ssh_key_allowed_algorithms }
.form-row
.col.form-group
= f.label :title, s_('Profiles|Title'), class: 'label-bold'
- = f.text_field :title, class: "form-control gl-form-input input-lg qa-key-title-field", required: true, placeholder: s_('Profiles|Example: MacBook key')
+ = f.text_field :title, class: "form-control gl-form-input input-lg", required: true, placeholder: s_('Profiles|Example: MacBook key'), data: { qa_selector: 'key_title_field' }
%p.form-text.text-muted= s_('Profiles|Key titles are publicly visible.')
.form-row
@@ -29,4 +29,4 @@
button_options: { class: 'js-add-ssh-key-validation-confirm-submit' }) do
= _("Yes, add it")
.gl-mt-3
- = f.submit s_('Profiles|Add key'), class: "js-add-ssh-key-validation-original-submit qa-add-key-button", pajamas_button: true
+ = f.submit s_('Profiles|Add key'), class: "js-add-ssh-key-validation-original-submit", pajamas_button: true, data: { qa_selector: 'add_key_button' }
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index 23a0d824bfe..efc1e23d9b4 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -1,3 +1,4 @@
+- add_page_specific_style 'page_bundles/notifications'
- page_title _('Notifications')
- @content_class = "limit-container-width" unless fluid_layout
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index 636defb3f10..82df6b1b2c7 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -25,6 +25,6 @@
scopes: @scopes,
help_path: help_page_path('user/profile/personal_access_tokens.md', anchor: 'personal-access-token-scopes')
- #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_personal_access_tokens.to_json } }
+ #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json } }
#js-tokens-app{ data: { tokens_data: tokens_app_data } }
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index dfaa4c31cdf..a140d780180 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -62,7 +62,7 @@
%p= s_("Profiles|Set your local time zone.")
.col-lg-8
= f.label :user_timezone, _("Time zone")
- .js-timezone-dropdown{ data: { timezone_data: timezone_data.to_json, initial_value: @user.timezone } }
+ .js-timezone-dropdown{ data: { timezone_data: timezone_data.to_json, initial_value: @user.timezone, name: 'user[timezone]' } }
.col-lg-12
%hr
.row.js-search-settings-section
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 319c6333e77..51222784847 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -6,7 +6,7 @@
- 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
+#tree-holder.tree-holder.clearfix.js-per-page{ data: { blame_per_page: Projects::BlameService::PER_PAGE } }
.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
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index 42cdc1d6989..cc5271a1cd2 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -10,45 +10,32 @@
.import-buttons
- if gitlab_project_import_enabled?
.import_gitlab_project.has-tooltip{ data: { container: 'body', qa_selector: 'gitlab_import_button' } }
- = link_to '#', class: 'gl-button btn-default btn 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
- .gl-button-icon
- = sprite_icon('tanuki')
- = _("GitLab export")
+ = 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
- = link_to new_import_github_path(namespace_id: namespace_id), class: 'gl-button btn-default btn js-import-github js-import-project-btn', data: { platform: 'github', **tracking_attrs_data(track_label, 'click_button', 'github') } do
- .gl-button-icon
- = sprite_icon('github')
+ = 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
GitHub
- if bitbucket_import_enabled?
%div
- = link_to status_import_bitbucket_path(namespace_id: namespace_id), class: "gl-button btn-default btn import_bitbucket js-import-project-btn #{'js-how-to-import-link' unless bitbucket_import_configured?}",
- data: { modal_title: _("Import projects from Bitbucket"), modal_message: import_from_bitbucket_message, platform: 'bitbucket_cloud', **tracking_attrs_data(track_label, 'click_button', 'bitbucket_cloud') } do
- .gl-button-icon
- = sprite_icon('bitbucket')
+ = render Pajamas::ButtonComponent.new(href: status_import_bitbucket_path(namespace_id: namespace_id), icon: 'bitbucket', button_options: { class: "import_bitbucket js-import-project-btn #{'js-how-to-import-link' unless bitbucket_import_configured?}", data: { modal_title: _("Import projects from Bitbucket"), modal_message: import_from_bitbucket_message, platform: 'bitbucket_cloud', **tracking_attrs_data(track_label, 'click_button', 'bitbucket_cloud') } }) do
Bitbucket Cloud
+
- if bitbucket_server_import_enabled?
%div
- = link_to status_import_bitbucket_server_path(namespace_id: namespace_id), class: "gl-button btn-default btn import_bitbucket js-import-project-btn", data: { platform: 'bitbucket_server', **tracking_attrs_data(track_label, 'click_button', 'bitbucket_server') } do
- .gl-button-icon
- = sprite_icon('bitbucket')
+ = render Pajamas::ButtonComponent.new(href: status_import_bitbucket_server_path(namespace_id: namespace_id), icon: 'bitbucket', button_options: { class: 'import_bitbucket js-import-project-btn', data: { platform: 'bitbucket_server', **tracking_attrs_data(track_label, 'click_button', 'bitbucket_server') } }) do
Bitbucket Server
- %div
- - if gitlab_import_enabled?
- %div
- = link_to status_import_gitlab_path(namespace_id: namespace_id), class: "gl-button btn-default btn import_gitlab js-import-project-btn #{'js-how-to-import-link' unless gitlab_import_configured?}",
- data: { modal_title: _("Import projects from GitLab.com"), modal_message: import_from_gitlab_message, platform: 'gitlab_com', **tracking_attrs_data(track_label, 'click_button', 'gitlab_com') } do
- .gl-button-icon
- = sprite_icon('tanuki')
- = _("GitLab.com")
- if fogbugz_import_enabled?
%div
- = link_to new_import_fogbugz_path(namespace_id: namespace_id), class: 'gl-button btn-default btn import_fogbugz js-import-project-btn', data: { platform: 'fogbugz', **tracking_attrs_data(track_label, 'click_button', 'fogbugz') } do
- .gl-button-icon
- = sprite_icon('bug')
+ = render Pajamas::ButtonComponent.new(href: new_import_fogbugz_path(namespace_id: namespace_id), icon: 'bug', button_options: { class: 'import_fogbugz js-import-project-btn', data: { platform: 'fogbugz', **tracking_attrs_data(track_label, 'click_button', 'fogbugz') } }) do
FogBugz
- if gitea_import_enabled?
@@ -60,24 +47,20 @@
- if git_import_enabled?
%div
- %button.gl-button.btn-default.btn.btn-svg.js-toggle-button.js-import-git-toggle-button.js-import-project-btn{ type: "button", data: { platform: 'repo_url', toggle_open_class: 'active', **tracking_attrs_data(track_label, 'click_button', 'repo_url') } }
- .gl-button-icon
- = sprite_icon('link', css_class: 'gl-icon')
+ = render Pajamas::ButtonComponent.new(icon: 'link', button_options: { class: 'js-toggle-button js-import-git-toggle-button js-import-project-btn', data: { platform: 'repo_url', toggle_open_class: 'active', **tracking_attrs_data(track_label, 'click_button', 'repo_url') } }) do
= _('Repository by URL')
- if manifest_import_enabled?
%div
- = link_to new_import_manifest_path(namespace_id: namespace_id), class: 'gl-button btn-default btn import_manifest js-import-project-btn', data: { platform: 'manifest_file', **tracking_attrs_data(track_label, 'click_button', 'manifest_file') } do
- .gl-button-icon
- = sprite_icon('doc-text')
- Manifest file
+ = 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
- = link_to new_import_phabricator_path(namespace_id: namespace_id), class: 'gl-button btn-default btn import_phabricator js-import-project-btn', data: { platform: 'phabricator', track_label: "#{track_label}", track_action: "click_button", track_property: "phabricator" } do
- .gl-button-icon
- = custom_icon('issues')
- = _("Phabricator Tasks")
+ = 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
.js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') }
diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml
index 859f065377d..5b493772f0a 100644
--- a/app/views/projects/_last_push.html.haml
+++ b/app/views/projects/_last_push.html.haml
@@ -16,5 +16,5 @@
- if create_mr_button_from_event?(event)
= c.actions do
- = render Pajamas::ButtonComponent.new(variant: :confirm, href: create_mr_path_from_push_event(event), button_options: { class: 'qa-create-merge-request' }) 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/_merge_request_merge_checks_settings.html.haml b/app/views/projects/_merge_request_merge_checks_settings.html.haml
index 3345b3043b8..8c12399fdbb 100644
--- a/app/views/projects/_merge_request_merge_checks_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_checks_settings.html.haml
@@ -12,6 +12,7 @@
s_('ProjectSettings|Skipped pipelines are considered successful'),
help_text: s_('ProjectSettings|Introduces the risk of merging changes that do not pass the pipeline.'),
checkbox_options: { class: 'gl-pl-6' }
+ = render_if_exists 'projects/merge_request_merge_checks_status_checks', form: form, project: @project
= form.gitlab_ui_checkbox_component :only_allow_merge_if_all_discussions_are_resolved,
s_('ProjectSettings|All threads must be resolved'),
checkbox_options: { data: { qa_selector: 'allow_merge_if_all_discussions_are_resolved_checkbox' } }
diff --git a/app/views/projects/_transfer.html.haml b/app/views/projects/_transfer.html.haml
index e3aa2d8afc9..93fc8d12960 100644
--- a/app/views/projects/_transfer.html.haml
+++ b/app/views/projects/_transfer.html.haml
@@ -3,7 +3,7 @@
- hidden_input_id = "new_namespace_id"
- initial_data = { button_text: s_('ProjectSettings|Transfer project'), confirm_danger_message: transfer_project_message(@project), phrase: @project.name, target_form_id: form_id, target_hidden_input_id: hidden_input_id, project_id: @project.id }
-.sub-section
+.sub-section{ data: { qa_selector: 'transfer_project_content' } }
%h4.danger-title= _('Transfer project')
= form_for @project, url: transfer_project_path(@project), method: :put, html: { class: 'js-project-transfer-form', id: form_id } do |f|
.form-group
@@ -20,5 +20,4 @@
%li= _('You will need to update your local repositories to point to the new location.')
%li= _('Project visibility level will be changed to match namespace rules when transferring to a group.')
= hidden_field_tag(hidden_input_id)
- = label_tag :new_namespace_id, _('Select a new namespace'), class: 'gl-font-weight-bold'
.js-transfer-project-form{ data: initial_data }
diff --git a/app/views/projects/_visibility_modal.html.haml b/app/views/projects/_visibility_modal.html.haml
deleted file mode 100644
index e8a4e091dcf..00000000000
--- a/app/views/projects/_visibility_modal.html.haml
+++ /dev/null
@@ -1,29 +0,0 @@
-- strong_start = "<strong>".html_safe
-- strong_end = "</strong>".html_safe
-
-.modal.js-confirm-project-visiblity{ tabindex: -1 }
- .modal-dialog
- .modal-content
- .modal-header
- %h1.page-title.gl-font-size-h-display= _('Reduce this project’s visibility?')
- %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
- %span{ "aria-hidden": "true" }= sprite_icon("close")
- .modal-body
- %p
- - if @project.group
- = _("You're about to reduce the visibility of the project %{strong_start}%{project_name}%{strong_end} in %{strong_start}%{group_name}%{strong_end}.").html_safe % { project_name: @project.name, group_name: @project.group.name, strong_start: strong_start, strong_end: strong_end }
- - else
- = _("You're about to reduce the visibility of the project %{strong_start}%{project_name}%{strong_end}.").html_safe % { project_name: @project.name, strong_start: strong_start, strong_end: strong_end }
- %p
- = _('Once you confirm and press "Reduce project visibility":')
- %ul
- %li
- = _("Current forks will keep their visibility level.").html_safe
- %label{ for: "confirm_path_input" }
- = _("To confirm, type %{phrase_code}").html_safe % { phrase_code: '<code class="js-legacy-confirm-danger-match">%{phrase_name}</code>'.html_safe % { phrase_name: @project.full_path } }
- .form-group
- = text_field_tag 'confirm_path_input', '', class: 'form-control js-legacy-confirm-danger-input'
- .form-actions
- %button.btn.gl-button.btn-default.gl-mr-4{ type: "button", "data-dismiss": "modal" }
- = _('Cancel')
- = submit_tag _('Reduce project visibility'), class: "btn gl-button btn-danger js-legacy-confirm-danger-submit", disabled: true
diff --git a/app/views/projects/artifacts/_artifact.html.haml b/app/views/projects/artifacts/_artifact.html.haml
deleted file mode 100644
index 9e548582396..00000000000
--- a/app/views/projects/artifacts/_artifact.html.haml
+++ /dev/null
@@ -1,61 +0,0 @@
-.gl-responsive-table-row.px-md-3
- .table-section.section-25.section-wrap.commit
- .table-mobile-header{ role: 'rowheader' }= _('Job')
- .table-mobile-content
- .branch-commit.cgray
- - if can?(current_user, :read_build, @project)
- = link_to project_job_path(@project, artifact.job) do
- %span.build-link ##{artifact.job_id}
- - else
- %span.build-link ##{artifact.job_id}
-
- - if artifact.job.ref
- .icon-container.gl-display-inline-block{ "aria-label" => artifact.job.tag? ? _('Tag') : _('Branch') }
- = artifact.job.tag? ? sprite_icon('tag', css_class: 'sprite') : sprite_icon('branch', css_class: 'sprite')
- = link_to artifact.job.ref, project_ref_path(@project, artifact.job.ref), class: 'ref-name'
- - else
- .light= _('none')
- .icon-container.commit-icon{ "aria-label" => _('Commit') }
- = sprite_icon('commit')
-
- - if artifact.job.sha
- = link_to artifact.job.short_sha, project_commit_path(@project, artifact.job.sha), class: 'commit-sha mr-0'
-
- .table-section.section-15.section-wrap
- .table-mobile-header{ role: 'rowheader' }= _('Name')
- .table-mobile-content
- = artifact.job.name
-
- .table-section.section-20
- .table-mobile-header{ role: 'rowheader' }= _('Creation date')
- .table-mobile-content
- %p.finished-at
- = sprite_icon("calendar")
- %span= time_ago_with_tooltip(artifact.created_at)
-
- .table-section.section-20
- .table-mobile-header{ role: 'rowheader' }= _('Expiration date')
- .table-mobile-content
- - if artifact.expire_at
- %p.finished-at
- = sprite_icon("calendar")
- %span= time_ago_with_tooltip(artifact.expire_at)
-
- .table-section.section-10
- .table-mobile-header{ role: 'rowheader' }= _('Size')
- .table-mobile-content
- = number_to_human_size(artifact.size, precision: 2)
-
- .table-section.table-button-footer.section-10
- .table-action-buttons
- .btn-group
- - if can?(current_user, :read_build, @project)
- = link_to download_project_job_artifacts_path(@project, artifact.job), rel: 'nofollow', download: '', title: _('Download artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Download artifacts') }, class: 'gl-button btn btn-default btn-icon has-tooltip' do
- = sprite_icon('download', css_class: 'gl-icon')
-
- = link_to browse_project_job_artifacts_path(@project, artifact.job), rel: 'nofollow', title: _('Browse artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Browse artifacts') }, class: 'gl-button btn btn-default btn-icon has-tooltip' do
- = sprite_icon('folder-open', css_class: 'gl-icon')
-
- - if can?(current_user, :destroy_artifacts, @project)
- = link_to project_artifact_path(@project, artifact), data: { placement: 'top', container: 'body', confirm: _('Are you sure you want to delete these artifacts?'), confirm_btn_variant: "danger" }, method: :delete, title: _('Delete artifacts'), ref: 'tooltip', aria: { label: _('Delete artifacts') }, class: 'gl-button btn btn-danger btn-icon has-tooltip' do
- = sprite_icon('remove', css_class: 'gl-icon')
diff --git a/app/views/projects/artifacts/_table.html.haml b/app/views/projects/artifacts/_table.html.haml
deleted file mode 100644
index 1963449d704..00000000000
--- a/app/views/projects/artifacts/_table.html.haml
+++ /dev/null
@@ -1,16 +0,0 @@
-- if artifacts.blank?
- .nothing-here-block= _('No jobs to show')
-- else
- .table-holder
- .ci-table
- .gl-responsive-table-row.table-row-header.px-md-3{ role: 'row' }
- .table-section.section-25{ role: 'rowheader' }= _('Job')
- .table-section.section-15{ role: 'rowheader' }= _('Name')
- .table-section.section-20{ role: 'rowheader' }= _('Creation date')
- .table-section.section-20{ role: 'rowheader' }= _('Expiration date')
- .table-section.section-10{ role: 'rowheader' }= _('Size')
- .table-section.section-10{ role: 'rowheader' }
-
- = render partial: 'artifact', collection: artifacts, as: :artifact
-
- = paginate artifacts, theme: "gitlab", total_pages: @total_pages
diff --git a/app/views/projects/artifacts/index.html.haml b/app/views/projects/artifacts/index.html.haml
index 1ab3e8e67d8..9cbc149177c 100644
--- a/app/views/projects/artifacts/index.html.haml
+++ b/app/views/projects/artifacts/index.html.haml
@@ -1,10 +1,9 @@
-- @no_container = true
- page_title _('Artifacts')
%div{ class: container_class }
- .top-area.py-3
- .align-self-center
- = _('Total artifacts size: %{total_size}') % { total_size: number_to_human_size(@total_size, precicion: 2) }
-
- .content-list.builds-content-list
- = render "table", artifacts: @artifacts, project: @project
+ %h1.page-title.gl-font-size-h-display.gl-mb-0
+ = s_('Artifacts|Artifacts')
+ .gl-mb-6
+ %strong= s_('Artifacts|Total artifacts size')
+ = number_to_human_size(@total_size, precicion: 2)
+ #js-artifact-management{ data: { "project-path" => @project.full_path } }
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index 9fd542e0cfb..17d5ef69b76 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -13,7 +13,7 @@
#js-code-owners{ data: { blob_path: blob.path, project_path: @project.full_path, branch: @ref } }
= render "projects/blob/auxiliary_viewer", blob: blob
-#blob-content-holder.blob-content-holder
+#blob-content-holder.blob-content-holder.js-per-page{ data: { blame_per_page: Projects::BlameService::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
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index bd08ab67cd3..a907e175443 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -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",
+ = text_field_tag 'file_name', params[:file_name], placeholder: "File name", 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?
@@ -38,7 +38,7 @@
.file-editor.code
- if Feature.enabled?(:source_editor_toolbar, current_user)
#editor-toolbar
- .js-edit-mode-pane.qa-editor#editor{ data: { 'editor-loading': true, qa_selector: 'source_editor_preview_container' } }<
+ .js-edit-mode-pane#editor{ data: { 'editor-loading': true, qa_selector: 'source_editor_preview_container' } }<
%pre.editor-loading-content= params[:content] || local_assigns[:blob_data]
- if local_assigns[:path]
.js-edit-mode-pane#preview.hide
diff --git a/app/views/projects/blob/viewers/_loading.html.haml b/app/views/projects/blob/viewers/_loading.html.haml
index 9cb934da7c0..d8efaf9ad95 100644
--- a/app/views/projects/blob/viewers/_loading.html.haml
+++ b/app/views/projects/blob/viewers/_loading.html.haml
@@ -1 +1 @@
-= gl_loading_icon(size: "md", css_class: "qa-spinner gl-my-4")
+= gl_loading_icon(size: "md", css_class: "gl-my-4", data: { qa_selector: 'spinner_placeholder' })
diff --git a/app/views/projects/branch_defaults/_branch_names_fields.html.haml b/app/views/projects/branch_defaults/_branch_names_fields.html.haml
new file mode 100644
index 00000000000..65f975fbd9e
--- /dev/null
+++ b/app/views/projects/branch_defaults/_branch_names_fields.html.haml
@@ -0,0 +1,14 @@
+- if @project.project_feature.issues_access_level > 0
+ %fieldset#branch-names-settings
+ .form-group
+ = f.label :issue_branch_template, _('Branch name template'), class: 'label-bold'
+ %p= s_('ProjectSettings|Branches created from issues follow this pattern.')
+
+ .form-group
+ .gl-mb-2
+ = f.text_field :issue_branch_template, class: 'form-control gl-mb-2', placeholder: "%{id}-%{title}"
+ %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/repository/web_editor.md', anchor: 'create-a-new-branch-from-an-issue')
+ = link_to _('What variables can I use?'), branch_name_help_link, target: "_blank"
diff --git a/app/views/projects/branch_defaults/_default_branch_fields.html.haml b/app/views/projects/branch_defaults/_default_branch_fields.html.haml
new file mode 100644
index 00000000000..e4f51725f1a
--- /dev/null
+++ b/app/views/projects/branch_defaults/_default_branch_fields.html.haml
@@ -0,0 +1,16 @@
+%fieldset#default-branch-settings
+ - if @project.empty_repo?
+ .text-secondary
+ = s_('ProjectSettings|A default branch cannot be chosen for an empty project.')
+ - else
+ .form-group
+ = f.label :default_branch, _("Default branch"), class: 'label-bold'
+ %p= s_('ProjectSettings|All merge requests and commits are made against this branch unless you specify a different one.')
+ .js-select-default-branch{ data: { default_branch: @project.default_branch, project_id: @project.id } }
+
+ .form-group
+ - help_text = _("When merge requests and commits in the default branch close, any issues they reference also close.")
+ - help_icon = link_to sprite_icon('question-o'), help_page_path('user/project/issues/managing_issues.md', anchor: 'closing-issues-automatically'), target: '_blank', rel: 'noopener noreferrer'
+ = f.gitlab_ui_checkbox_component :autoclose_referenced_issues,
+ s_('ProjectSettings|Auto-close referenced issues on default branch'),
+ help_text: (help_text + "&nbsp;" + help_icon).html_safe
diff --git a/app/views/projects/branch_defaults/_show.html.haml b/app/views/projects/branch_defaults/_show.html.haml
new file mode 100644
index 00000000000..4ecbc3b7fc8
--- /dev/null
+++ b/app/views/projects/branch_defaults/_show.html.haml
@@ -0,0 +1,17 @@
+- expanded = expanded_by_default?
+
+%section.settings.no-animate#branch-defaults-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Branch defaults')
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ = s_('ProjectSettings|Select the default branch for this project, and configure the template for branch names.')
+
+ .settings-content
+ - url = namespace_project_settings_repository_path(@project.namespace, @project)
+ = gitlab_ui_form_for @project, url: url, method: :put, html: { multipart: true, class: "issue-settings-form js-issue-settings-form" }, authenticity_token: true do |f|
+ %input{ name: 'update_section', type: 'hidden', value: 'js-issue-settings' }
+ = render 'projects/branch_defaults/default_branch_fields', f: f
+ = render 'projects/branch_defaults/branch_names_fields', f: f
+ = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 52b8d6bc66f..51c218f40b9 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -1,19 +1,19 @@
- 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 } }
+%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 qa-branch-name' do
+ = 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' }
+ = 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' } }
+ = 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' }
+ = 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
@@ -46,4 +46,5 @@
= render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name], class: 'gl-vertical-align-top'
- = render 'projects/branches/delete_branch_modal_button', project: @project, branch: branch, merged: merged
+ - 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/_panel.html.haml b/app/views/projects/branches/_panel.html.haml
index 6ca5aaf061e..a1f93d21647 100644
--- a/app/views/projects/branches/_panel.html.haml
+++ b/app/views/projects/branches/_panel.html.haml
@@ -11,7 +11,7 @@
- c.header do
= panel_title
- c.body do
- %ul.content-list.all-branches.qa-all-branches
+ %ul.content-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
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index 295b2de9bd2..475bc9e1c20 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -1,3 +1,4 @@
+- add_page_specific_style 'page_bundles/branches'
- page_title _('Branches')
- add_to_breadcrumbs(_('Repository'), project_tree_path(@project))
@@ -6,21 +7,16 @@
= 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') }
= gl_tab_link_to s_('Branches|Stale'), project_branches_filtered_path(@project, state: 'stale'), { title: s_('Branches|Show stale branches') }
- = gl_tab_link_to s_('Branches|All'), project_branches_filtered_path(@project, state: 'all'), { item_active: !%w[overview active stale].include?(@mode), title: s_('Branches|Show all branches') }
+ = gl_tab_link_to s_('Branches|All'), project_branches_filtered_path(@project, state: 'all'), { item_active: %w[overview active stale].exclude?(@mode), title: s_('Branches|Show all branches') }
.nav-controls
#js-branches-sort-dropdown{ data: { project_branches_filtered_path: project_branches_path(@project, state: 'all'), sort_options: branches_sort_options_hash.to_json, mode: @mode } }
- if can? current_user, :push_code, @project
- = link_to project_merged_branches_path(@project),
- class: 'gl-button btn btn-danger btn-danger-secondary has-tooltip qa-delete-merged-branches',
- title: s_("Branches|Delete all branches that are merged into '%{default_branch}'") % { default_branch: @project.repository.root_ref },
- method: :delete,
- aria: { label: s_('Branches|Delete merged branches') },
- data: { confirm: s_('Branches|Deleting the merged branches cannot be undone. Are you sure?'),
- confirm_btn_variant: 'danger',
- container: 'body' } do
- = s_('Branches|Delete merged branches')
+ .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')
diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml
index 2d32e07d379..23dcb7f41e1 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -1,3 +1,5 @@
+- project = local_assigns.fetch(:project)
+- ref = local_assigns.fetch(:ref)
- pipeline = local_assigns.fetch(:pipeline) { project.latest_successful_pipeline_for(ref) }
- if !project.empty_repo? && can?(current_user, :download_code, project)
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 23572d1d6ac..6e202063900 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -30,7 +30,7 @@
= preserve(markdown_field(@commit, :description))
.info-well.js-commit-box-info{ 'data-commit-path' => branches_project_commit_path(@project, @commit.id) }
- .well-segment.branch-info
+ .well-segment
.icon-container.commit-icon
= custom_icon("icon_commit")
%span.cgray= n_('parent', 'parents', @commit.parents.count)
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index bf6b628dd36..b5481f19352 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -34,8 +34,8 @@
&middot;
= commit.short_id
- if commit.description? && collapsible
- %button.gl-button.btn.btn-default.button-ellipsis-horizontal.btn-sm.gl-ml-2.text-expander.js-toggle-button{ data: { toggle: 'tooltip', container: 'body' }, :title => _("Toggle commit description"), aria: { label: _("Toggle commit description") } }
- = sprite_icon('ellipsis_h', size: 12)
+ = render Pajamas::ButtonComponent.new(icon: 'ellipsis_h',
+ button_options: { class: 'button-ellipsis-horizontal text-expander js-toggle-button', data: { toggle: 'tooltip', container: 'body' }, :title => _("Toggle commit description"), aria: { label: _("Toggle commit description") }})
.committer
- commit_author_link = commit_author_link(commit, avatar: false, size: 24)
diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml
index bb3a38d6ac8..b5ecc9b0193 100644
--- a/app/views/projects/commits/_commits.html.haml
+++ b/app/views/projects/commits/_commits.html.haml
@@ -33,16 +33,15 @@
- else
= render partial: 'projects/commits/commit', collection: context_commits, locals: { project: project, ref: ref, merge_request: merge_request }
-- if hidden > 0
+- if hidden > 0 && !@merge_request
%li
= render Pajamas::AlertComponent.new(variant: :warning,
dismissible: false) do |c|
= c.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?
- = render Pajamas::ButtonComponent.new(button_options: { class: 'gl-mt-5 add-review-item-modal-trigger', data: { context_commits_empty: 'true' } }) do
- = _('Add previously merged commits')
+- if can_update_merge_request && context_commits&.empty? && !(defined?(@next_page) && @next_page)
+ .add-review-item-modal-trigger{ data: { context_commits_empty: 'true' } }
- if commits.size == 0 && context_commits.nil?
.commits-empty.gl-mt-6
diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml
deleted file mode 100644
index 04712cd59f7..00000000000
--- a/app/views/projects/default_branch/_show.html.haml
+++ /dev/null
@@ -1,29 +0,0 @@
-- expanded = expanded_by_default?
-
-%section.settings.no-animate#default-branch-settings{ class: ('expanded' if expanded) }
- .settings-header
- %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Default branch')
- = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
- = expanded ? _('Collapse') : _('Expand')
- %p
- = _('Set the default branch for this project. All merge requests and commits are made against this branch unless you specify a different one.')
-
- .settings-content
- = gitlab_ui_form_for @project, remote: true, html: { multipart: true, anchor: 'default-branch-settings' }, authenticity_token: true do |f|
- %fieldset
- - if @project.empty_repo?
- .text-secondary
- = _('A default branch cannot be chosen for an empty project.')
- - else
- .form-group
- = f.label :default_branch, _("Default branch"), class: 'label-bold'
- .js-select-default-branch{ data: { default_branch: @project.default_branch, project_id: @project.id } }
-
- .form-group
- - help_text = _("When merge requests and commits in the default branch close, any issues they reference also close.")
- - help_icon = link_to sprite_icon('question-o'), help_page_path('user/project/issues/managing_issues.md', anchor: 'closing-issues-automatically'), target: '_blank', rel: 'noopener noreferrer'
- = f.gitlab_ui_checkbox_component :autoclose_referenced_issues,
- _("Auto-close referenced issues on default branch"),
- help_text: (help_text + "&nbsp;" + help_icon).html_safe
-
- = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true
diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml
index c0fe143020a..e1c8992a28c 100644
--- a/app/views/projects/deployments/_actions.haml
+++ b/app/views/projects/deployments/_actions.haml
@@ -1,4 +1,4 @@
-- if can?(current_user, :create_deployment, deployment)
+- if can?(current_user, :read_deployment, deployment)
- actions = deployment.manual_actions
- if actions.present?
.btn-group
@@ -8,7 +8,7 @@
= sprite_icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right
- actions.each do |action|
- - next unless can?(current_user, :update_build, action)
+ - next unless can?(current_user, :play_job, action)
%li
= link_to [:play, @project, action], method: :post, rel: 'nofollow' do
%span= action.name
diff --git a/app/views/projects/deployments/_rollback.haml b/app/views/projects/deployments/_rollback.haml
index 223f7520b47..e50fa1fa0f7 100644
--- a/app/views/projects/deployments/_rollback.haml
+++ b/app/views/projects/deployments/_rollback.haml
@@ -1,4 +1,4 @@
-- if deployment.deployable && can?(current_user, :create_deployment, deployment)
+- if deployment.deployable && can?(current_user, :play_job, deployment.deployable)
- tooltip = deployment.last? ? s_('Environments|Re-deploy to environment') : s_('Environments|Rollback environment')
- icon = deployment.last? ? 'repeat' : 'redo'
= render Pajamas::ButtonComponent.new(icon: icon, button_options: { title: tooltip, class: 'js-confirm-rollback-modal-button has-tooltip', data: { environment_name: @environment.name, commit_short_sha: deployment.short_sha, commit_url: project_commit_path(@project, deployment.sha), is_last_deployment: deployment.last?.to_s, retry_path: retry_project_job_path(@environment.project, deployment.deployable) } })
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 6f2e135f9d3..43159a759f4 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -3,8 +3,6 @@
- escaped_default_branch_name = default_branch_name.shellescape
- @skip_current_level_breadcrumb = true
-= render_if_exists 'projects/free_user_cap_alert', project: @project
-
= render partial: 'flash_messages', locals: { project: @project }
= render 'clusters_deprecation_alert'
@@ -20,7 +18,7 @@
%p
= _('You can get started by cloning the repository or start adding files to it with one of the following options.')
-.project-buttons.qa-quick-actions
+.project-buttons{ data: { qa_selector: 'quick_actions_container' } }
.project-clone-holder.d-block.d-md-none.gl-mt-3.gl-mr-3
= render "shared/mobile_clone_panel"
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
index ca71990f5e3..3e63faaf448 100644
--- a/app/views/projects/hooks/edit.html.haml
+++ b/app/views/projects/hooks/edit.html.haml
@@ -9,7 +9,7 @@
= render 'shared/web_hooks/title_and_docs', hook: @hook
.col-lg-9.gl-mb-3
- = gitlab_ui_form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f|
+ = gitlab_ui_form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook), html: { class: 'js-webhook-form' } do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
= f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/projects/hooks/index.html.haml b/app/views/projects/hooks/index.html.haml
index 0476193c2cb..15cb7869dc5 100644
--- a/app/views/projects/hooks/index.html.haml
+++ b/app/views/projects/hooks/index.html.haml
@@ -7,8 +7,8 @@
= render 'shared/web_hooks/title_and_docs', hook: @hook
.col-lg-8.gl-mb-3
- = gitlab_ui_form_for @hook, as: :hook, url: polymorphic_path([@project, :hooks]) do |f|
+ = gitlab_ui_form_for @hook, as: :hook, url: polymorphic_path([@project, :hooks]), html: { class: 'js-webhook-form' } do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
- = f.submit 'Add webhook', pajamas_button: true
+ = f.submit _('Add webhook'), pajamas_button: true, data: { qa_selector: "create_webhook_button" }
= render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class
diff --git a/app/views/projects/incidents/show.html.haml b/app/views/projects/incidents/show.html.haml
index 7a1e7f503f8..6d733dc46df 100644
--- a/app/views/projects/incidents/show.html.haml
+++ b/app/views/projects/incidents/show.html.haml
@@ -2,6 +2,7 @@
- add_to_breadcrumbs _("Incidents"), project_incidents_path(@project)
- breadcrumb_title @issue.to_reference
- page_title "#{@issue.title} (#{@issue.to_reference})", _("Incidents")
+- add_page_specific_style 'page_bundles/design_management'
- add_page_specific_style 'page_bundles/incidents'
- add_page_specific_style 'page_bundles/issues_show'
diff --git a/app/views/projects/issues/_related_issues.html.haml b/app/views/projects/issues/_related_issues.html.haml
index 1c252958525..80f2b8b189c 100644
--- a/app/views/projects/issues/_related_issues.html.haml
+++ b/app/views/projects/issues/_related_issues.html.haml
@@ -4,4 +4,5 @@
full_path: @project.full_path,
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 } }
+ show_categorized_issues: @project.licensed_feature_available?(:blocked_issues).to_s,
+ has_iterations_feature: @project.licensed_feature_available?(:iterations).to_s } }
diff --git a/app/views/projects/issues/_work_item_links.html.haml b/app/views/projects/issues/_work_item_links.html.haml
index c0de711136a..72f9ec2ff16 100644
--- a/app/views/projects/issues/_work_item_links.html.haml
+++ b/app/views/projects/issues/_work_item_links.html.haml
@@ -1,2 +1 @@
-- if Feature.enabled?(:work_items_hierarchy, @project)
- .js-work-item-links-root{ data: { issuable_id: @issue.id, iid: @issue.iid, project_namespace: @project.namespace.path, project_path: @project.full_path, wi: work_items_index_data(@project) } }
+.js-work-item-links-root{ data: { issuable_id: @issue.id, iid: @issue.iid, project_path: @project.full_path, wi: work_items_index_data(@project) } }
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 76b725d140c..a8edf87b696 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -2,6 +2,7 @@
- add_to_breadcrumbs _("Issues"), project_issues_path(@project)
- breadcrumb_title @issue.to_reference
- page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues")
+- add_page_specific_style 'page_bundles/design_management'
- add_page_specific_style 'page_bundles/incidents'
- add_page_specific_style 'page_bundles/issues_show'
- add_page_specific_style 'page_bundles/work_items'
diff --git a/app/views/projects/merge_requests/_awards_block.html.haml b/app/views/projects/merge_requests/_awards_block.html.haml
index 820927fdd1a..c1952793e72 100644
--- a/app/views/projects/merge_requests/_awards_block.html.haml
+++ b/app/views/projects/merge_requests/_awards_block.html.haml
@@ -1,2 +1,2 @@
-.content-block.emoji-block.emoji-list-container.js-noteable-awards
+.emoji-block.emoji-list-container.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @merge_request, inline: true, api_awards_path: award_emoji_merge_request_api_path(@merge_request)
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 478db70877d..78fce3f7087 100644
--- a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
+++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
@@ -1,7 +1,7 @@
- display_issuable_type = issuable_display_type(@merge_request)
-.float-left.btn-group.gl-md-ml-3.gl-display-flex.dropdown.gl-new-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' } do
+.btn-group.gl-md-ml-3.gl-display-flex.dropdown.gl-new-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
= 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-new-dropdown-button-text= _('Merge request actions')
@@ -11,7 +11,7 @@
.gl-new-dropdown-contents
%ul
- if current_user && moved_mr_sidebar_enabled?
- %li.gl-new-dropdown-item.js-sidebar-subscriptions-entry-point
+ %li.gl-new-dropdown-item.js-sidebar-subscriptions-widget-root
%li.gl-new-dropdown-divider
%hr.dropdown-divider
- if can?(current_user, :update_merge_request, @merge_request)
@@ -36,7 +36,7 @@
= _('Reopen')
= display_issuable_type
- if moved_mr_sidebar_enabled?
- %li.gl-new-dropdown-item#js-lock-entry-point
+ %li.gl-new-dropdown-item.js-sidebar-lock-root
%li.gl-new-dropdown-item
%button.dropdown-item.js-copy-reference{ type: "button", data: { 'clipboard-text': @merge_request.to_reference(full: true) } }
.gl-new-dropdown-item-text-wrapper
diff --git a/app/views/projects/merge_requests/_code_dropdown.html.haml b/app/views/projects/merge_requests/_code_dropdown.html.haml
index bb42c3067d9..5c7fe56095c 100644
--- a/app/views/projects/merge_requests/_code_dropdown.html.haml
+++ b/app/views/projects/merge_requests/_code_dropdown.html.haml
@@ -1,4 +1,4 @@
-.float-left.gl-md-ml-3.dropdown.gl-new-dropdown{ class: "gl-display-none! gl-md-display-flex!" }
+.gl-md-ml-3.dropdown.gl-new-dropdown{ class: "gl-display-none! gl-md-display-flex!" }
#js-check-out-modal{ data: how_merge_modal_data(@merge_request) }
= button_tag type: 'button', class: "btn dropdown-toggle btn-confirm gl-button gl-dropdown-toggle", data: { toggle: 'dropdown', qa_selector: 'mr_code_dropdown' } do
%span.gl-new-dropdown-button-text= _('Code')
@@ -16,12 +16,12 @@
= _('Check out branch')
- if current_user
%li.gl-new-dropdown-item
- = link_to ide_merge_request_path(@merge_request), class: 'dropdown-item', data: { qa_selector: 'open_in_web_ide_button' } do
+ = link_to ide_merge_request_path(@merge_request), class: 'dropdown-item', target: '_blank', data: { qa_selector: 'open_in_web_ide_button' } do
.gl-new-dropdown-item-text-wrapper
= _('Open in Web IDE')
- if Gitlab::CurrentSettings.gitpod_enabled && current_user&.gitpod_enabled
%li.gl-new-dropdown-item
- = link_to "#{Gitlab::CurrentSettings.gitpod_url}##{merge_request_url(@merge_request)}", class: 'dropdown-item' do
+ = link_to "#{Gitlab::CurrentSettings.gitpod_url}##{merge_request_url(@merge_request)}", target: '_blank', class: 'dropdown-item' do
.gl-new-dropdown-item-text-wrapper
= _('Open in Gitpod')
%li.gl-new-dropdown-divider
diff --git a/app/views/projects/merge_requests/_commits.html.haml b/app/views/projects/merge_requests/_commits.html.haml
index 7cadc37b0fd..ee0ab984d6f 100644
--- a/app/views/projects/merge_requests/_commits.html.haml
+++ b/app/views/projects/merge_requests/_commits.html.haml
@@ -13,6 +13,9 @@
- else
%ol#commits-list.list-unstyled
= render "projects/commits/commits", merge_request: @merge_request
+ - if @next_page && @merge_request
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-load-more-commits', data: { next_page: @next_page } }) do
+ = _('Load more')
-- if can_update_merge_request && @merge_request.iid
+- if can_update_merge_request && @merge_request.iid && !@next_page
.add-review-item-modal-wrapper{ data: { context_commits_path: context_commits_project_json_merge_request_url(@merge_request&.project, @merge_request, :json), target_branch: @merge_request.target_branch, merge_request_iid: @merge_request.iid, project_id: @merge_request.project.id } }
diff --git a/app/views/projects/merge_requests/_mr_box.html.haml b/app/views/projects/merge_requests/_mr_box.html.haml
index 4fc405c63ff..901a2ebfd1e 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{ class: "#{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" }
+.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}" }
= 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 893f03157db..a73d2aa5cc4 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -25,9 +25,9 @@
- display_class = moved_mr_sidebar_enabled? ? 'gl-md-display-none!' : 'gl-sm-display-none!'
= render Pajamas::ButtonComponent.new(icon: "chevron-double-lg-left", button_options: { class: "btn-icon float-right gl-display-block gutter-toggle issuable-gutter-toggle js-sidebar-toggle #{display_class}" })
- .detail-page-header-actions.gl-align-self-start.is-merge-request.js-issuable-actions
+ .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 btn-grouped js-issuable-edit", data: { qa_selector: "edit_button" }
+ = 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" }
- if @merge_request.source_project
= render 'projects/merge_requests/code_dropdown'
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 ef3174efcc7..1246c45a529 100644
--- a/app/views/projects/merge_requests/creations/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml
@@ -14,7 +14,7 @@
- if @commits.empty?
.commits-empty
%h4
- There are no commits yet.
+ = _("There are no commits yet.")
= custom_icon ('illustration_no_commits')
- else
.merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
@@ -25,16 +25,16 @@
%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
+ = _("Commits")
= gl_badge_tag @total_commit_count, { size: :sm }, { class: 'gl-tab-counter-badge' }
- if @pipelines.any?
%li.builds-tab
= link_to url_for(safe_params.merge(action: 'pipelines')), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tabvue'} do
- Pipelines
+ = _("Pipelines")
= gl_badge_tag @pipelines.size, { size: :sm }, { class: 'gl-tab-counter-badge' }
%li.diffs-tab
= link_to url_for(safe_params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tabvue', qa_selector: 'diffs_tab'} do
- Changes
+ = _("Changes")
= gl_badge_tag @merge_request.diff_size, { size: :sm }, { class: 'gl-tab-counter-badge' }
#diff-notes-app.tab-content
diff --git a/app/views/projects/merge_requests/dropdowns/_branch.html.haml b/app/views/projects/merge_requests/dropdowns/_branch.html.haml
index a60c445aa51..d6af8b2f5a5 100644
--- a/app/views/projects/merge_requests/dropdowns/_branch.html.haml
+++ b/app/views/projects/merge_requests/dropdowns/_branch.html.haml
@@ -1,5 +1,5 @@
%ul
- branches.each do |branch|
%li
- %a{ href: '#', class: "#{('is-active' if selected == branch)}", title: branch, data: { id: branch } }
+ %a{ href: '#', class: "#{'is-active' if selected == branch}", title: branch, data: { id: branch } }
= branch
diff --git a/app/views/projects/merge_requests/dropdowns/_project.html.haml b/app/views/projects/merge_requests/dropdowns/_project.html.haml
index b3cf3c1d369..41c0b0ab55b 100644
--- a/app/views/projects/merge_requests/dropdowns/_project.html.haml
+++ b/app/views/projects/merge_requests/dropdowns/_project.html.haml
@@ -1,5 +1,5 @@
%ul
- projects.each do |project|
%li
- %a{ href: "#", class: "#{('is-active' if selected == project.id)}", data: { id: project.id, 'refs-url': refs_project_path(project) } }
+ %a{ href: "#", class: "#{'is-active' if selected == project.id}", data: { id: project.id, 'refs-url': refs_project_path(project) } }
= project.full_path
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index d77d5231a7d..203724fc1f1 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -8,12 +8,15 @@
- page_card_attributes @merge_request.card_attributes
- suggest_changes_help_path = help_page_path('user/project/merge_requests/reviews/suggestions.md')
- mr_action = j(params[:tab].presence || 'show')
+- add_page_specific_style 'page_bundles/design_management'
- add_page_specific_style 'page_bundles/merge_requests'
- add_page_specific_style 'page_bundles/pipelines'
- add_page_specific_style 'page_bundles/reports'
- add_page_specific_style 'page_bundles/ci_status'
- add_page_startup_api_call @endpoint_metadata_url
+- if mr_action == 'diffs'
+ - 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 } }
- if moved_mr_sidebar_enabled?
@@ -47,7 +50,7 @@
#js-vue-discussion-counter{ data: { blocks_merge: @project.only_allow_merge_if_all_discussions_are_resolved?.to_s } }
- if moved_mr_sidebar_enabled?
- if !!@issuable_sidebar.dig(:current_user, :id)
- .js-issuable-todo{ data: { project_path: @issuable_sidebar[:project_full_path], iid: @issuable_sidebar[:iid], id: @issuable_sidebar[:id] } }
+ .js-sidebar-todo-widget-root{ data: { project_path: @issuable_sidebar[:project_full_path], iid: @issuable_sidebar[:iid], id: @issuable_sidebar[:id] } }
.gl-ml-auto.gl-align-items-center.gl-display-none.gl-md-display-flex.gl-ml-3.js-expand-sidebar{ class: "gl-lg-display-none!" }
= render Pajamas::ButtonComponent.new(icon: 'chevron-double-lg-left',
button_options: { class: 'js-sidebar-toggle' }) do
diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml
index fb7c1130f5c..cdb8a63bca9 100644
--- a/app/views/projects/milestones/_form.html.haml
+++ b/app/views/projects/milestones/_form.html.haml
@@ -25,8 +25,8 @@
.form-actions
- if @milestone.new_record?
- = f.submit _('Create milestone'), class: 'gl-button btn-confirm btn', data: { qa_selector: 'create_milestone_button' }
+ = 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'), class: 'gl-button btn-confirm btn'
+ = f.submit _('Save changes'), 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/mirrors/_authentication_method.html.haml b/app/views/projects/mirrors/_authentication_method.html.haml
index 4b549aaf1cd..54e1f1a8b20 100644
--- a/app/views/projects/mirrors/_authentication_method.html.haml
+++ b/app/views/projects/mirrors/_authentication_method.html.haml
@@ -5,10 +5,10 @@
= f.label :auth_method, _('Authentication method'), class: 'label-bold'
= f.select :auth_method,
options_for_select(auth_options, mirror.auth_method),
- {}, { class: "custom-select gl-form-select js-mirror-auth-type qa-authentication-method" }
+ {}, { class: "custom-select gl-form-select js-mirror-auth-type", data: { qa_selector: 'authentication_method_field' } }
= f.hidden_field :auth_method, value: "password", class: "js-hidden-mirror-auth-type"
.form-group
.well-password-auth.collapse.js-well-password-auth
= f.label :password, _("Password"), class: "label-bold"
- = f.password_field :password, class: 'form-control gl-form-input qa-password js-mirror-password-field', autocomplete: 'off'
+ = f.password_field :password, class: 'form-control gl-form-input js-mirror-password-field', autocomplete: 'off', data: { qa_selector: 'password_field' }
diff --git a/app/views/projects/mirrors/_disabled_mirror_badge.html.haml b/app/views/projects/mirrors/_disabled_mirror_badge.html.haml
index 86e54acecc4..1834627c705 100644
--- a/app/views/projects/mirrors/_disabled_mirror_badge.html.haml
+++ b/app/views/projects/mirrors/_disabled_mirror_badge.html.haml
@@ -1,2 +1,2 @@
-%span.qa-disabled-mirror-badge.rspec-disabled-mirror-badge{ data: { toggle: 'tooltip', html: 'true' }, title: _('Disabled mirrors can only be enabled by instance owners. It is recommended that you delete them.') }
+%span.rspec-disabled-mirror-badge{ data: { toggle: 'tooltip', html: 'true' }, title: _('Disabled mirrors can only be enabled by instance owners. It is recommended that you delete them.') }
= gl_badge_tag _('Disabled'), variant: :warning
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index c98f88fa31e..f4e57450aa1 100644
--- a/app/views/projects/mirrors/_mirror_repos.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos.html.haml
@@ -21,7 +21,7 @@
.form-group.has-feedback
= label_tag :url, _('Git repository URL'), class: 'label-light'
- = text_field_tag :url, nil, class: 'form-control gl-form-input js-mirror-url js-repo-url qa-mirror-repository-url-input', placeholder: _('Input the remote repository URL'), required: true, pattern: "(#{protocols}):\/\/.+", autocomplete: 'new-password'
+ = text_field_tag :url, nil, class: 'form-control gl-form-input js-mirror-url js-repo-url', placeholder: _('Input the remote repository URL'), required: true, pattern: "(#{protocols}):\/\/.+", autocomplete: 'new-password', data: { qa_selector: 'mirror_repository_url_field' }
= render 'projects/mirrors/instructions'
@@ -35,7 +35,7 @@
= link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index.md', anchor: 'mirror-only-protected-branches'), target: '_blank', rel: 'noopener noreferrer'
.panel-footer
- = f.submit _('Mirror repository'), class: 'js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror, pajamas_button: true
+ = 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
diff --git a/app/views/projects/mirrors/_mirror_repos_form.html.haml b/app/views/projects/mirrors/_mirror_repos_form.html.haml
index 34b7c75debf..1322e677d5a 100644
--- a/app/views/projects/mirrors/_mirror_repos_form.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos_form.html.haml
@@ -1,7 +1,7 @@
.form-group
= label_tag :mirror_direction, _('Mirror direction'), class: 'label-light'
.select-wrapper
- = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control gl-form-select select-control js-mirror-direction qa-mirror-direction', disabled: true
+ = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control gl-form-select select-control js-mirror-direction', disabled: true, data: { qa_selector: 'mirror_direction_field' }
= sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200")
= render partial: "projects/mirrors/mirror_repos_push", locals: { f: f }
diff --git a/app/views/projects/mirrors/_mirror_repos_list.html.haml b/app/views/projects/mirrors/_mirror_repos_list.html.haml
index 2dbcbd659c8..fb8133e6de8 100644
--- a/app/views/projects/mirrors/_mirror_repos_list.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos_list.html.haml
@@ -25,17 +25,17 @@
= render_if_exists 'projects/mirrors/table_pull_row'
- @project.remote_mirrors.each_with_index do |mirror, index|
- next if mirror.new_record?
- %tr.rspec-mirrored-repository-row{ class: ('bg-secondary' if mirror.disabled?), data: { qa_selector: 'mirrored_repository_row' } }
- %td{ data: { qa_selector: 'mirror_repository_url_cell' } }= mirror.safe_url || _('Invalid URL')
+ %tr.rspec-mirrored-repository-row{ class: ('bg-secondary' if mirror.disabled?), data: { qa_selector: 'mirrored_repository_row_container' } }
+ %td{ data: { qa_selector: 'mirror_repository_url_content' } }= mirror.safe_url || _('Invalid URL')
%td= _('Push')
%td
= mirror.last_update_started_at.present? ? time_ago_with_tooltip(mirror.last_update_started_at) : _('Never')
- %td{ data: { qa_selector: 'mirror_last_update_at_cell' } }= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never')
+ %td{ data: { qa_selector: 'mirror_last_update_at_content' } }= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never')
%td
- if mirror.disabled?
= render 'projects/mirrors/disabled_mirror_badge'
- if mirror.last_error.present?
- = gl_badge_tag _('Error'), { variant: :danger }, { data: { toggle: 'tooltip', html: 'true', qa_selector: 'mirror_error_badge' }, title: html_escape(mirror.last_error.try(:strip)) }
+ = gl_badge_tag _('Error'), { variant: :danger }, { data: { toggle: 'tooltip', html: 'true', qa_selector: 'mirror_error_badge_content' }, title: html_escape(mirror.last_error.try(:strip)) }
%td.gl-display-flex
- if mirror_settings_enabled
.btn-group.mirror-actions-group{ role: 'group' }
@@ -44,4 +44,4 @@
= render 'shared/remote_mirror_update_button', remote_mirror: mirror
= render Pajamas::ButtonComponent.new(variant: :danger,
icon: 'remove',
- button_options: { class: 'js-delete-mirror qa-delete-mirror rspec-delete-mirror', title: _('Remove'), data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' } })
+ button_options: { class: 'js-delete-mirror rspec-delete-mirror', title: _('Remove'), data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' } })
diff --git a/app/views/projects/ml/experiments/_experiment.html.haml b/app/views/projects/ml/experiments/_experiment.html.haml
new file mode 100644
index 00000000000..42823f47469
--- /dev/null
+++ b/app/views/projects/ml/experiments/_experiment.html.haml
@@ -0,0 +1,3 @@
+%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
new file mode 100644
index 00000000000..a25e814b2b5
--- /dev/null
+++ b/app/views/projects/ml/experiments/_experiment_list.html.haml
@@ -0,0 +1,7 @@
+- 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
new file mode 100644
index 00000000000..e34f3fd2d2f
--- /dev/null
+++ b/app/views/projects/ml/experiments/_incubation_banner.html.haml
@@ -0,0 +1,8 @@
+= 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
new file mode 100644
index 00000000000..a84cb15d940
--- /dev/null
+++ b/app/views/projects/ml/experiments/index.html.haml
@@ -0,0 +1,11 @@
+- breadcrumb_title _('ML Experiments')
+- page_title _('ML Experiments')
+
+.page-title-holder.d-flex.align-items-center
+ %h1.page-title.gl-font-size-h-display= _('Machine Learning Experiments')
+
+= render "incubation_banner"
+
+%div{ class: container_class }
+ .content-list.builds-content-list
+ = render "experiment_list", experiments: @experiments, project: @project
diff --git a/app/views/projects/ml/experiments/show.html.haml b/app/views/projects/ml/experiments/show.html.haml
new file mode 100644
index 00000000000..2c350439762
--- /dev/null
+++ b/app/views/projects/ml/experiments/show.html.haml
@@ -0,0 +1,14 @@
+- add_to_breadcrumbs _("Experiments"), project_ml_experiments_path(@project)
+- breadcrumb_title @experiment.name
+- page_title @experiment.name
+- items = candidates_table_items(@candidates)
+- metrics = unique_logged_names(@candidates, &:latest_metrics)
+- params = unique_logged_names(@candidates, &:params)
+
+.page-title-holder.d-flex.align-items-center
+ %h1.page-title.gl-font-size-h-display= @experiment.name
+
+#js-show-ml-experiment{ data: {
+ candidates: items,
+ metrics: metrics,
+ params: params } }
diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml
index b6700c9ed1e..2a3171e9fd8 100644
--- a/app/views/projects/network/show.html.haml
+++ b/app/views/projects/network/show.html.haml
@@ -6,8 +6,7 @@
.controls.gl-bg-gray-50.gl-p-2.gl-font-base.gl-text-gray-400.gl-border-b-1.gl-border-b-solid.gl-border-b-gray-300
= form_tag project_network_path(@project, @id), method: :get, class: 'form-inline network-form' do |f|
= text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: _("Git revision"), class: 'search-input form-control gl-form-input input-mx-250 search-sha gl-mr-2'
- = button_tag class: 'btn gl-button btn-confirm btn-icon' do
- = sprite_icon('search')
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, icon: 'search')
.inline.gl-ml-5
.form-check.light
= check_box_tag :filter_ref, 1, @options[:filter_ref], class: 'form-check-input'
diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml
index a8a30d73000..e3f46d601a3 100644
--- a/app/views/projects/no_repo.html.haml
+++ b/app/views/projects/no_repo.html.haml
@@ -1,8 +1,6 @@
- page_title _('No repository')
- @skip_current_level_breadcrumb = true
-= render_if_exists 'projects/free_user_cap_alert', project: @project
-
%h2.gl-display-flex
.gl-display-flex.gl-align-items-center.gl-justify-content-center
= sprite_icon('warning-solid', size: 24, css_class: 'gl-mr-2')
diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml
index 9a8b83649de..6b875ff904c 100644
--- a/app/views/projects/notes/_actions.html.haml
+++ b/app/views/projects/notes/_actions.html.haml
@@ -9,14 +9,16 @@
- if can?(current_user, :award_emoji, note)
- if note.emoji_awardable?
.note-actions-item
- = button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip btn gl-button btn-icon btn-default-tertiary", data: { position: 'right', container: 'body' } do
+ = render Pajamas::ButtonComponent.new(category: :tertiary,
+ button_options: { title: _('Add reaction'), class: 'btn-icon note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip', data: { position: 'right', container: 'body' }, 'aria-label': _('Add reaction') }) do
= sprite_icon('slight-smile', css_class: 'award-control-icon-neutral gl-button-icon gl-icon')
= sprite_icon('smiley', css_class: 'award-control-icon-positive gl-button-icon gl-icon gl-left-3!')
= sprite_icon('smile', css_class: 'award-control-icon-super-positive gl-button-icon gl-icon gl-left-3! ')
- if note_editable
.note-actions-item.gl-ml-0
- = button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn gl-button btn-default-tertiary btn-icon', data: { container: 'body', qa_selector: 'edit_comment_button' } do
- = sprite_icon('pencil', css_class: 'gl-button-icon gl-icon')
+ = render Pajamas::ButtonComponent.new(category: :tertiary,
+ icon: 'pencil',
+ button_options: { class: 'note-action-button js-note-edit has-tooltip', data: { container: 'body', qa_selector: 'edit_comment_button' }, title: _('Edit comment'), 'aria-label': _('Edit comment') })
= render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable
diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml
index ab692d1830a..235b89b8c5b 100644
--- a/app/views/projects/pipeline_schedules/_form.html.haml
+++ b/app/views/projects/pipeline_schedules/_form.html.haml
@@ -9,10 +9,10 @@
= f.label :cron, _('Interval Pattern'), class: 'label-bold'
#interval-pattern-input{ data: { initial_interval: @schedule.cron, daily_limit: @schedule.daily_limit } }
.form-group.row
- .col-md-9
- = f.label :cron_timezone, _('Cron Timezone'), class: 'label-bold'
- = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'gl-button btn btn-default js-timezone-dropdown w-100', dropdown_class: 'w-100', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } )
- = f.text_field :cron_timezone, value: @schedule.cron_timezone, id: 'schedule_cron_timezone', class: 'hidden', name: 'schedule[cron_timezone]', required: true
+ .col-md-9{ data: { testid: 'schedule-timezone' } }
+ = f.label :cron_timezone, _("Cron Timezone")
+ .js-timezone-dropdown{ data: { timezone_data: timezone_data.to_json, initial_value: @schedule.cron_timezone, name: 'schedule[cron_timezone]' } }
+
.form-group.row
.col-md-9
= f.label :ref, _('Target branch or tag'), class: 'label-bold'
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 2e403358e2e..30cc7f94311 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -1,9 +1,13 @@
-.commit-box
- %h3.commit-title
- = markdown(commit.title, pipeline: :single_line)
- - if commit.description.present?
- %pre.commit-description<
- = preserve(markdown(commit.description, pipeline: :single_line))
+- if Feature.enabled?(:pipeline_name, @pipeline.project) && @pipeline.name
+ %h3
+ = @pipeline.name
+- else
+ .commit-box
+ %h3.commit-title
+ = markdown(commit.title, pipeline: :single_line)
+ - if commit.description.present?
+ %pre.commit-description<
+ = preserve(markdown(commit.description, pipeline: :single_line))
.info-well
.well-segment.pipeline-info{ class: "gl-align-items-baseline!" }
@@ -19,7 +23,7 @@
= s_("Pipelines|(queued for %{queued_duration})") % { queued_duration: time_interval_in_words(@pipeline.queued_duration)}
- if has_pipeline_badges?(@pipeline)
- .well-segment.qa-pipeline-badges
+ .well-segment
.icon-container
= sprite_icon('flag', css_class: 'gl-top-0!')
- if @pipeline.schedule?
@@ -45,11 +49,16 @@
- if @pipeline.stuck?
= gl_badge_tag s_('Pipelines|stuck'), { variant: :warning, size: :sm }, { class: 'js-pipeline-url-stuck has-tooltip' }
- .well-segment.branch-info
+ .well-segment{ 'data-testid': 'commit-row' }
.icon-container.commit-icon
= sprite_icon('commit', css_class: 'gl-top-0!')
- = link_to commit.short_id, project_commit_path(@project, @pipeline.sha), class: "commit-sha"
- = clipboard_button(text: @pipeline.sha, title: _("Copy commit SHA"))
+ - if Feature.enabled?(:pipeline_name, @pipeline.project) && @pipeline.name
+ = markdown(commit.title, pipeline: :single_line)
+ = clipboard_button(text: @pipeline.sha, title: _("Copy commit SHA"))
+ = link_to commit.short_id, project_commit_path(@project, @pipeline.sha), class: "commit-sha"
+ - else
+ = link_to commit.short_id, project_commit_path(@project, @pipeline.sha), class: "commit-sha"
+ = clipboard_button(text: @pipeline.sha, title: _("Copy commit SHA"))
.well-segment.related-merge-request-info
.icon-container
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index c9eb2e92193..4531bb2d0a9 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -30,4 +30,4 @@
#js-pipeline-tabs{ data: js_pipeline_tabs_data(@project, @pipeline, @current_user) }
- else
= render "projects/pipelines/with_tabs", pipeline: @pipeline, stages: @stages, pipeline_has_errors: pipeline_has_errors
-.js-pipeline-details-vue{ data: { metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: @project.namespace, project_id: @project, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline) } }
+.js-pipeline-details-vue{ data: { metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: @project.namespace, project_id: @project, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline), pipeline_path: pipeline_path(@pipeline) } }
diff --git a/app/views/projects/product_analytics/_graph.html.haml b/app/views/projects/product_analytics/_graph.html.haml
deleted file mode 100644
index fd81a248005..00000000000
--- a/app/views/projects/product_analytics/_graph.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-- graph = local_assigns.fetch(:graph)
-
-%h3
- = graph[:id]
-
-.js-project-analytics-chart{ "data-chart-data": graph.to_json, "data-chart-id": graph[:id] }
diff --git a/app/views/projects/product_analytics/_links.html.haml b/app/views/projects/product_analytics/_links.html.haml
deleted file mode 100644
index 6e5667e2644..00000000000
--- a/app/views/projects/product_analytics/_links.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-= gl_tabs_nav({ class: 'mb-3'}) do
- = gl_tab_link_to _('Events'), project_product_analytics_path(@project)
- = gl_tab_link_to _('Graphs'), graphs_project_product_analytics_path(@project)
- = gl_tab_link_to _('Test'), test_project_product_analytics_path(@project)
- = gl_tab_link_to _('Setup'), setup_project_product_analytics_path(@project)
diff --git a/app/views/projects/product_analytics/_tracker.html.erb b/app/views/projects/product_analytics/_tracker.html.erb
deleted file mode 100644
index dbb96f19e22..00000000000
--- a/app/views/projects/product_analytics/_tracker.html.erb
+++ /dev/null
@@ -1,10 +0,0 @@
-;(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","<%= product_analytics_tracker_url -%>","snowplow<%= @random -%>"));
-snowplow<%= @random -%>("newTracker", "sp", "<%= product_analytics_tracker_collector_url -%>", {
- appId: "<%= @project_id -%>",
- platform: "<%= @platform -%>",
- eventMethod: "get"
-});
-snowplow<%= @random -%>('trackPageView');
diff --git a/app/views/projects/product_analytics/graphs.html.haml b/app/views/projects/product_analytics/graphs.html.haml
deleted file mode 100644
index c345561e6ce..00000000000
--- a/app/views/projects/product_analytics/graphs.html.haml
+++ /dev/null
@@ -1,16 +0,0 @@
-- page_title _('Product Analytics')
-
-= render 'links'
-
-%p
- = _('Showing graphs based on events of the last %{timerange} days.') % { timerange: @timerange }
-
-
-.gl-mb-3
- = render 'graph', graph: @activity_graph
-
-- @graphs.each_slice(2) do |pair|
- .row.append-bottom-10
- - pair.each do |graph|
- .col-md-6{ id: graph[:id] }
- = render 'graph', graph: graph
diff --git a/app/views/projects/product_analytics/index.html.haml b/app/views/projects/product_analytics/index.html.haml
deleted file mode 100644
index 386f9265179..00000000000
--- a/app/views/projects/product_analytics/index.html.haml
+++ /dev/null
@@ -1,16 +0,0 @@
-- page_title _('Product Analytics')
-
-= render 'links'
-
-- if @events.any?
- %p
- - if @events.total_count > @events.size
- = _('Number of events for this project: %{total_count}.') % { total_count: number_with_delimiter(@events.total_count) }
- %ol
- - @events.each do |event|
- %li
- %code= event.as_json_wo_empty
-- else
- .empty-state
- .text-content
- = _('There are currently no events.')
diff --git a/app/views/projects/product_analytics/setup.html.haml b/app/views/projects/product_analytics/setup.html.haml
deleted file mode 100644
index e1819c7d74b..00000000000
--- a/app/views/projects/product_analytics/setup.html.haml
+++ /dev/null
@@ -1,12 +0,0 @@
-- page_title _('Product Analytics')
-
-= render 'links'
-
-%p
- = _('Copy the code below to implement tracking in your application:')
-
-%pre
- = render "tracker"
-
-%p.hint
- = _('A platform value can be web, mob or app.')
diff --git a/app/views/projects/product_analytics/test.html.haml b/app/views/projects/product_analytics/test.html.haml
deleted file mode 100644
index 3204cd5fbbe..00000000000
--- a/app/views/projects/product_analytics/test.html.haml
+++ /dev/null
@@ -1,17 +0,0 @@
-- page_title _('Product Analytics')
-
-= render 'links'
-
-%p
- = _('This page sends a payload. Go back to the events page to see a newly created event.')
-
-- if @event
- %p
- = _('Last item before this page loaded in your browser:')
-
- %code
- = @event.as_json_wo_empty
-
--# haml-lint:disable InlineJavaScript
-:javascript
- #{render 'tracker'}
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index 34305d15eff..c7818602f52 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -1,7 +1,6 @@
- add_page_specific_style 'page_bundles/members'
- page_title _("Members")
-= render_if_exists 'projects/free_user_cap_alert', project: @project
= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project
.row.gl-mt-3
diff --git a/app/views/projects/protected_branches/shared/_dropdown.html.haml b/app/views/projects/protected_branches/shared/_dropdown.html.haml
index d5111bd8be5..c5dbf8991cd 100644
--- a/app/views/projects/protected_branches/shared/_dropdown.html.haml
+++ b/app/views/projects/protected_branches/shared/_dropdown.html.haml
@@ -3,14 +3,16 @@
= f.hidden_field(:name)
= dropdown_tag(_('Select branch or create wildcard'),
- options: { toggle_class: "js-protected-branch-select js-filter-submit wide monospace qa-protected-branch-select #{toggle_classes}",
+ options: { toggle_class: "js-protected-branch-select js-filter-submit wide monospace #{toggle_classes}",
filter: true,
- dropdown_class: "dropdown-menu-selectable git-revision-dropdown qa-protected-branch-dropdown",
+ dropdown_class: "dropdown-menu-selectable git-revision-dropdown",
+ dropdown_qa_selector: "protected_branch_dropdown_content",
placeholder: _("Search protected branches"),
footer_content: true,
data: { show_no: true, show_any: true, show_upcoming: true,
selected: params[:protected_branch_name],
- project_id: @project.try(:id) } }) do
+ project_id: @project.try(:id),
+ qa_selector: "protected_branch_dropdown" } }) do
%ul.dropdown-footer-list
%li
diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml
index 1d60791eae2..c204508d355 100644
--- a/app/views/projects/protected_branches/shared/_index.html.haml
+++ b/app/views/projects/protected_branches/shared/_index.html.haml
@@ -4,7 +4,7 @@
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= s_("ProtectedBranch|Protected branches")
- = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle qa-expand-protected-branches' }) do
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
%p
= s_("ProtectedBranch|Keep stable branches secure and force developers to use merge requests.")
diff --git a/app/views/projects/protected_tags/shared/_dropdown.html.haml b/app/views/projects/protected_tags/shared/_dropdown.html.haml
index 9c7f532fa29..9d5d649bc40 100644
--- a/app/views/projects/protected_tags/shared/_dropdown.html.haml
+++ b/app/views/projects/protected_tags/shared/_dropdown.html.haml
@@ -1,8 +1,8 @@
= f.hidden_field(:name)
-= dropdown_tag('Select tag or create wildcard',
+= dropdown_tag(s_('ProtectedBranch|Select tag or create wildcard'),
options: { toggle_class: 'js-protected-tag-select js-filter-submit wide monospace',
- filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header git-revision-dropdown", placeholder: "Search protected tags",
+ filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header git-revision-dropdown", placeholder: s_("ProtectedBranch|Search protected tags"),
footer_content: true,
data: { show_no: true, show_any: true, show_upcoming: true,
selected: params[:protected_tag_name],
@@ -10,6 +10,6 @@
%ul.dropdown-footer-list
%li
- %button{ class: "dropdown-create-new-item-button js-dropdown-create-new-item", title: "New Protected Tag" }
- Create wildcard
+ %button{ class: "dropdown-create-new-item-button js-dropdown-create-new-item", title: s_("ProtectedBranch|New Protected Tag") }
+ = s_('ProtectedBranch|Create wildcard')
%code
diff --git a/app/views/projects/protected_tags/shared/_tags_list.html.haml b/app/views/projects/protected_tags/shared/_tags_list.html.haml
index 5f3ea281278..0a85a353e27 100644
--- a/app/views/projects/protected_tags/shared/_tags_list.html.haml
+++ b/app/views/projects/protected_tags/shared/_tags_list.html.haml
@@ -1,9 +1,9 @@
.protected-tags-list.js-protected-tags-list
- if @protected_tags.empty?
.card-header
- Protected tags (0)
+ = s_('ProtectedBranch|Protected tags (%{tags_count})') % { tags_count: 0 }
%p.settings-message.text-center
- No tags are protected.
+ = s_('ProtectedBranch|No tags are protected.')
- else
- can_admin_project = can?(current_user, :admin_project, @project)
@@ -16,9 +16,12 @@
%col
%thead
%tr
- %th Protected tags (#{@protected_tags_count})
- %th Last commit
- %th Allowed to create
+ %th
+ = s_('ProtectedBranch|Protected tags (%{tags_count})') % { tags_count: @protected_tags_count }
+ %th
+ = s_('ProtectedBranch|Last commit')
+ %th
+ = s_('ProtectedBranch|Allowed to create')
- if can_admin_project
%th
%tbody
diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml
index 2f97a068b49..70e14eadaf9 100644
--- a/app/views/projects/settings/_archive.html.haml
+++ b/app/views/projects/settings/_archive.html.haml
@@ -1,6 +1,6 @@
- return unless can?(current_user, :archive_project, @project)
-.sub-section{ data: { qa_selector: 'archive_project_content' } }
+.sub-section
%h4.warning-title
- if @project.archived?
= _('Unarchive project')
diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml
index 9f598ffb2d1..f6c5c4e2950 100644
--- a/app/views/projects/settings/access_tokens/index.html.haml
+++ b/app/views/projects/settings/access_tokens/index.html.haml
@@ -40,5 +40,5 @@
description_prefix: :project_access_token,
help_path: help_page_path('user/project/settings/project_access_tokens', anchor: 'scopes-for-a-project-access-token')
- #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_resource_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true
+ #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true
} }
diff --git a/app/views/projects/settings/branch_rules/index.html.haml b/app/views/projects/settings/branch_rules/index.html.haml
index a7e80101a88..571a992a552 100644
--- a/app/views/projects/settings/branch_rules/index.html.haml
+++ b/app/views/projects/settings/branch_rules/index.html.haml
@@ -3,4 +3,4 @@
%h3.gl-mb-5= s_('BranchRules|Branch rules details')
-#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') } }
+#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') } }
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index 51d28411b30..68dc7f2be8d 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -1,6 +1,6 @@
- help_link_public_pipelines = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'change-which-users-can-view-your-pipelines'), target: '_blank', rel: 'noopener noreferrer'
- help_link_auto_canceling = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'auto-cancel-redundant-pipelines'), target: '_blank', rel: 'noopener noreferrer'
-- help_link_skip_outdated = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'skip-outdated-deployment-jobs'), target: '_blank', rel: 'noopener noreferrer'
+- help_link_skip_outdated = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'prevent-outdated-deployment-jobs'), target: '_blank', rel: 'noopener noreferrer'
- help_link_separated_caches = link_to sprite_icon('question-o'), help_page_path('ci/caching/index', anchor: 'cache-key-names'), target: '_blank', rel: 'noopener noreferrer'
.row.gl-mt-3
@@ -22,8 +22,8 @@
.form-group
= f.fields_for :ci_cd_settings_attributes, @project.ci_cd_settings do |form|
- = form.gitlab_ui_checkbox_component :forward_deployment_enabled, _("Skip outdated deployment jobs"),
- help_text: (_('When a deployment job is successful, skip older deployment jobs that are still pending.') + ' ' + help_link_skip_outdated).html_safe
+ = form.gitlab_ui_checkbox_component :forward_deployment_enabled, _("Prevent outdated deployment jobs"),
+ help_text: (_('When a deployment job is successful, prevent older deployment jobs that are still pending.') + ' ' + help_link_skip_outdated).html_safe
.form-group
= f.gitlab_ui_checkbox_component :ci_separated_caches,
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index c4f589f3f91..1b35de85145 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -57,7 +57,7 @@
.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') } }
-%section.qa-variables-settings.settings.no-animate#js-cicd-variables-settings{ class: ('expanded' if expanded), data: { qa_selector: 'variables_settings_content' } }
+%section.settings.no-animate#js-cicd-variables-settings{ class: ('expanded' if expanded), data: { qa_selector: 'variables_settings_content' } }
.settings-header
= render 'ci/variables/header', expanded: expanded
.settings-content
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index 500cfdcb62b..306ce47cee7 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -3,7 +3,7 @@
- @content_class = "limit-container-width" unless fluid_layout
- deploy_token_description = s_('DeployTokens|Deploy tokens allow access to packages, your repository, and registry images.')
-= render "projects/default_branch/show"
+= render "projects/branch_defaults/show"
- if Feature.enabled?(:branch_rules, @project)
= render "projects/branch_rules/show"
= render_if_exists "projects/push_rules/index"
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index c7ac28fa194..77c44b792ab 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -7,7 +7,6 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity")
-= render_if_exists 'projects/free_user_cap_alert', project: @project
= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project
= render partial: 'flash_messages', locals: { project: @project }
= render 'clusters_deprecation_alert'
diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml
index ddebc19be15..58e86ebffa0 100644
--- a/app/views/projects/snippets/show.html.haml
+++ b/app/views/projects/snippets/show.html.haml
@@ -3,9 +3,9 @@
- breadcrumb_title @snippet.to_reference
- page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
-#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet), 'can-report-spam': @snippet.submittable_as_spam_by?(current_user).to_s } }
+#js-snippet-view{ data: { 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet), 'can-report-spam': @snippet.submittable_as_spam_by?(current_user).to_s } }
-.row-content-block.top-block.content-component-block.gl-px-0.gl-py-2
+.gl-px-0.gl-py-2
= render 'award_emoji/awards_block', awardable: @snippet, inline: true, api_awards_path: project_snippets_award_api_path(@snippet)
#notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => true
diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml
index 9043b8e60fc..b6b24a0c26a 100644
--- a/app/views/projects/triggers/_form.html.haml
+++ b/app/views/projects/triggers/_form.html.haml
@@ -1,4 +1,4 @@
-= form_for [@project, @trigger], html: { class: 'gl-show-field-errors' } do |f|
+= gitlab_ui_form_for [@project, @trigger], html: { class: 'gl-show-field-errors' } do |f|
= form_errors(@trigger)
- if @trigger.token
@@ -8,4 +8,4 @@
.form-group
= f.label :key, "Description", class: "label-bold"
= f.text_field :description, class: 'form-control gl-form-input', required: true, title: 'Trigger description is required.', placeholder: "Trigger description"
- = f.submit btn_text, class: "gl-button btn btn-confirm"
+ = f.submit btn_text, pajamas_button: true
diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml
index fe455f4a0bc..283659875ef 100644
--- a/app/views/registrations/welcome/show.html.haml
+++ b/app/views/registrations/welcome/show.html.haml
@@ -7,7 +7,6 @@
= render "layouts/one_trust"
= render "layouts/bizible"
= render "layouts/google_tag_manager_body"
-
.row.gl-flex-grow-1
.d-flex.gl-flex-direction-column.gl-align-items-center.gl-w-full.gl-px-5.gl-pb-5
.edit-profile.login-page.d-flex.flex-column.gl-align-items-center
diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml
index 54aa9aad8a5..c15afd7bd5b 100644
--- a/app/views/search/_category.html.haml
+++ b/app/views/search/_category.html.haml
@@ -5,7 +5,7 @@
.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)
- = gl_tabs_nav({ class: 'search-filter scrolling-tabs nav-links'}) do
+ = gl_tabs_nav({ class: 'scrolling-tabs nav-links', data: { testid: 'search-filter' } }) do
- if @project
- if project_search_tabs?(:blobs)
= search_filter_link 'blobs', _("Code"), data: { qa_selector: 'code_tab' }
diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml
index 8262c3c90e1..027ae6bf77c 100644
--- a/app/views/search/_results.html.haml
+++ b/app/views/search/_results.html.haml
@@ -1,13 +1,12 @@
- search_bar_classes = 'search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4'
= render_if_exists 'shared/promotions/promote_advanced_search'
-- if Feature.enabled?(:search_page_vertical_nav, current_user) && %w[issues merge_requests].include?(@scope)
+- if Feature.enabled?(:search_page_vertical_nav, current_user)
.results.gl-md-display-flex.gl-mt-0
#js-search-sidebar{ class: search_bar_classes, data: { navigation: search_navigation_json } }
.gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden
= render partial: 'search/results_status', locals: { search_service: @search_service } unless @search_objects.to_a.empty?
= render partial: 'search/results_list'
-
- else
= render partial: 'search/results_status', locals: { search_service: @search_service } unless @search_objects.to_a.empty?
diff --git a/app/views/search/_results_status.html.haml b/app/views/search/_results_status.html.haml
index e6bb0c18b90..adea6b598f7 100644
--- a/app/views/search/_results_status.html.haml
+++ b/app/views/search/_results_status.html.haml
@@ -3,7 +3,6 @@
- return unless search_service.show_results_status?
- if Feature.enabled?(:search_page_vertical_nav, current_user)
- = render partial: 'search/results_status_vert_nav', locals: { search_service: @search_service }
-
+ = render partial: 'search/results_status_vert_nav', locals: { search_service: search_service }
- else
- = render partial: 'search/results_status_horiz_nav', locals: { search_service: @search_service }
+ = render partial: 'search/results_status_horiz_nav', locals: { search_service: search_service }
diff --git a/app/views/search/results/_blob_highlight.html.haml b/app/views/search/results/_blob_highlight.html.haml
index 7ba114496af..37ffabad717 100644
--- a/app/views/search/results/_blob_highlight.html.haml
+++ b/app/views/search/results/_blob_highlight.html.haml
@@ -4,7 +4,8 @@
#search-blob-content.file-content.code.js-syntax-highlight{ class: 'gl-py-3!' }
- if blob.present?
.blob-content{ data: { blob_id: blob.id, path: blob.path, highlight_line: highlight, qa_selector: 'file_content' } }
- - blob.present.highlight.lines.each_with_index do |line, index|
+ - blob_highlight = blob.present.highlight_and_trim(trim_length: 1024, ellipsis_svg: sprite_icon('ellipsis_h', size: 12, css_class: "gl-text-gray-700"))
+ - blob_highlight.lines.each_with_index do |line, index|
- i = index + offset
.line_holder.code-search-line.gl-display-flex
.line-numbers
diff --git a/app/views/shared/_file_picker_button.html.haml b/app/views/shared/_file_picker_button.html.haml
index 1d688e7f4b0..8d76e9c1b7d 100644
--- a/app/views/shared/_file_picker_button.html.haml
+++ b/app/views/shared/_file_picker_button.html.haml
@@ -1,7 +1,8 @@
- classes = local_assigns.fetch(:classes, '')
%span.js-filepicker
- %button.gl-button.btn.btn-default.js-filepicker-button{ type: 'button', class: classes }= _("Choose file…")
+ = 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"
- if help_text.present?
diff --git a/app/views/shared/_flash_user_callout.html.haml b/app/views/shared/_flash_user_callout.html.haml
index 7b2d59407b4..c549c4e6e4d 100644
--- a/app/views/shared/_flash_user_callout.html.haml
+++ b/app/views/shared/_flash_user_callout.html.haml
@@ -4,7 +4,7 @@
.flash-container.flash-container-page.user-callout{ data: callout_data }
-# We currently only support `alert`, `warning`, `notice`, `success`
%div{ class: "flash-#{flash_type}" }
- %div{ class: "#{(container_class unless fluid_layout)} #{(extra_flash_class unless @no_container)} #{@content_class}" }
+ %div{ class: "#{container_class unless fluid_layout} #{extra_flash_class unless @no_container} #{@content_class}" }
%span= message
%button.btn.gl-button.btn-default.close.js-close{ type: 'button',
'aria-label' => _('Dismiss') }
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index c0bc50fef5b..1645c2695b5 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -42,7 +42,7 @@
- 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
+ = 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
@@ -51,10 +51,10 @@
.dropdown-menu.dropdown-open-left
%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
+ = 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
+ = 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
diff --git a/app/views/shared/_md_preview.html.haml b/app/views/shared/_md_preview.html.haml
index 7314a7ddadc..2fff70cdc74 100644
--- a/app/views/shared/_md_preview.html.haml
+++ b/app/views/shared/_md_preview.html.haml
@@ -1,6 +1,6 @@
- referenced_users = local_assigns.fetch(:referenced_users, nil)
-- if defined?(@merge_request) && @merge_request.discussion_locked?
+- if @merge_request&.discussion_locked?
.issuable-note-warning
= sprite_icon('lock', css_class: 'icon')
%span
diff --git a/app/views/shared/access_tokens/_created_container.html.haml b/app/views/shared/access_tokens/_created_container.html.haml
deleted file mode 100644
index c0aaa46e761..00000000000
--- a/app/views/shared/access_tokens/_created_container.html.haml
+++ /dev/null
@@ -1,12 +0,0 @@
-.created-personal-access-token-container
- %h5.gl-mt-0
- = _('Your new %{type}') % { type: type }
- .form-group
- .input-group
- = text_field_tag 'created-personal-access-token', new_token_value, readonly: true, class: 'form-control js-select-on-focus', data: { qa_selector: 'created_access_token_field' }, 'aria-describedby' => 'created-token-help-block'
- %span.input-group-append
- = clipboard_button(text: new_token_value, title: _('Copy %{type}') % { type: type }, placement: 'left', class: 'input-group-text btn-default btn-clipboard')
- %span#created-token-help-block.form-text.text-muted.text-danger
- = _("Make sure you save it - you won't be able to access it again.")
-
-%hr
diff --git a/app/views/shared/access_tokens/_table.html.haml b/app/views/shared/access_tokens/_table.html.haml
deleted file mode 100644
index 53c6800f93d..00000000000
--- a/app/views/shared/access_tokens/_table.html.haml
+++ /dev/null
@@ -1,51 +0,0 @@
-- no_active_tokens_message = local_assigns.fetch(:no_active_tokens_message, _('This user has no active %{type}.') % { type: type_plural })
-- impersonation = local_assigns.fetch(:impersonation, false)
-- resource = local_assigns.fetch(:resource, false)
-
-%hr
-
-%h5
- = _('Active %{type} (%{token_length})') % { type: type_plural, token_length: active_tokens.length }
-
-- if impersonation
- %p.profile-settings-content
- = _("To see all the user's personal access tokens you must impersonate them first.")
-
-- if active_tokens.present?
- .table-responsive
- %table.table.active-tokens
- %thead
- %tr
- %th= _('Token name')
- %th= _('Scopes')
- %th= s_('AccessTokens|Created')
- %th
- = _('Last Used')
- = link_to sprite_icon('question-o'), help_page_path('user/profile/personal_access_tokens.md', anchor: 'view-the-last-time-a-token-was-used'), target: '_blank', rel: 'noopener noreferrer'
- %th= _('Expires')
- - if resource
- %th= _('Role')
- %th
- %tbody
- - active_tokens.each do |token|
- %tr
- %td= token.name
- %td= token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected')
- %td= token.created_at.to_date.to_s(:medium)
- %td
- - if token.last_used_at?
- %span.token-last-used-label= _(time_ago_with_tooltip(token.last_used_at))
- - else
- %span.token-never-used-label= _('Never')
- %td
- - if token.expires?
- %span{ class: ('text-warning' if token.expires_soon?) }
- = time_ago_with_tooltip(token.expires_at)
- - else
- %span.token-never-expires-label= _('Never')
- - if resource
- %td= resource.member(token.user).human_access
- %td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: "gl-button btn btn-danger btn-sm float-right #{'btn-danger-secondary' unless token.expires?}", aria: { label: _('Revoke') }, data: { confirm: _('Are you sure you want to revoke this %{type}? This action cannot be undone.') % { type: type }, 'confirm-btn-variant': 'danger', qa_selector: 'revoke_button' }
-- else
- .settings-message.text-center
- = no_active_tokens_message
diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml
index 1b48843eb10..0f290f34a95 100644
--- a/app/views/shared/deploy_tokens/_form.html.haml
+++ b/app/views/shared/deploy_tokens/_form.html.haml
@@ -3,7 +3,7 @@
- group_deploy_tokens_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_deploy_tokens_help_link_url }
= s_('DeployTokens|Create a new deploy token for all projects in this group. %{link_start}What are deploy tokens?%{link_end}').html_safe % { link_start: group_deploy_tokens_help_link_start, link_end: '</a>'.html_safe }
-= gitlab_ui_form_for token, url: create_deploy_token_path(group_or_project, anchor: 'js-deploy-tokens'), method: :post, remote: Feature.enabled?(:ajax_new_deploy_token, group_or_project) do |f|
+= gitlab_ui_form_for token, url: create_deploy_token_path(group_or_project, anchor: 'js-deploy-tokens'), method: :post, remote: true do |f|
.form-group
= f.label :name, class: 'label-bold'
diff --git a/app/views/shared/deploy_tokens/_index.html.haml b/app/views/shared/deploy_tokens/_index.html.haml
index faec379e42b..e5f1fd99125 100644
--- a/app/views/shared/deploy_tokens/_index.html.haml
+++ b/app/views/shared/deploy_tokens/_index.html.haml
@@ -8,20 +8,13 @@
%p
= description
.settings-content
- - if Feature.enabled?(:ajax_new_deploy_token, group_or_project)
- #js-new-deploy-token{ data: {
- container_registry_enabled: container_registry_enabled?(group_or_project),
- packages_registry_enabled: packages_registry_enabled?(group_or_project),
- create_new_token_path: create_deploy_token_path(group_or_project),
- token_type: group_or_project.is_a?(Group) ? 'group' : 'project',
- deploy_tokens_help_url: help_page_path('user/project/deploy_tokens/index.md')
- }
+ #js-new-deploy-token{ data: {
+ container_registry_enabled: container_registry_enabled?(group_or_project),
+ packages_registry_enabled: packages_registry_enabled?(group_or_project),
+ create_new_token_path: create_deploy_token_path(group_or_project),
+ token_type: group_or_project.is_a?(Group) ? 'group' : 'project',
+ deploy_tokens_help_url: help_page_path('user/project/deploy_tokens/index.md')
}
- - else
- - if @created_deploy_token
- = render 'shared/deploy_tokens/new_deploy_token', deploy_token: @created_deploy_token
- %h5.gl-mt-0
- = s_('DeployTokens|New deploy token')
- = render 'shared/deploy_tokens/form', group_or_project: group_or_project, token: @new_deploy_token, presenter: @deploy_tokens
+ }
%hr
= render 'shared/deploy_tokens/table', group_or_project: group_or_project, active_tokens: @deploy_tokens
diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml
index a006a3bc0a4..e34166bac6c 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
- = image_tag 'illustrations/snippets_empty.svg', data: { qa_selector: 'svg_content' }
+ .svg-content{ data: { qa_selector: 'svg_content' } }
+ = image_tag 'illustrations/snippets_empty.svg'
.text-content.gl-text-center.gl-pt-0
- if current_user
%h4
diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
index e6bdefc64d2..da8477f4b2e 100644
--- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml
+++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
@@ -1,5 +1,6 @@
- type = local_assigns.fetch(:type)
- is_issue = type == :issues
+- move_data = { projects_fetch_path: autocomplete_projects_path(project_id: @project.id), project_full_path: @project.full_path }
%aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? }, 'aria-label': _('Bulk update') }
.issuable-sidebar.hidden
@@ -33,7 +34,7 @@
.title
= _('Milestone')
.filter-item
- = dropdown_tag(_("Select milestone"), options: { title: _("Assign milestone"), toggle_class: "js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: _("Search milestones"), data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, use_id: true, default_label: _("Milestone") } })
+ .js-milestone-dropdown-root{ data: { full_path: @project.full_path, workspace_type: Namespaces::ProjectNamespace.sti_name.downcase } }
- if is_issue
= render_if_exists 'shared/issuable/iterations_dropdown', parent: @project.group
- if is_issue
@@ -42,6 +43,9 @@
.title
= _('Subscriptions')
.js-subscriptions-dropdown
+ - if is_issue
+ .block
+ .js-move-issues{ data: move_data }
= hidden_field_tag "update[issuable_ids]", []
= hidden_field_tag :state_event, params[:state_event]
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 5b7f9c4226c..a325ad5f447 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -71,7 +71,10 @@
- else
= link_to _('Cancel'), polymorphic_path([@project, issuable]), class: 'gl-button btn btn-default js-reset-autosave'
- if can?(current_user, :"destroy_#{issuable.to_ability_name}", @project)
- = link_to 'Delete', polymorphic_path([@project, issuable], params: { destroy_confirm: true }), data: { confirm: _('%{issuableType} will be removed! Are you sure?') % { issuableType: issuable.human_class_name } }, method: :delete, class: 'btn gl-button btn-danger btn-danger-secondary gl-float-right js-reset-autosave'
+ - confirm_title = _('Delete %{issuableType}?') % { issuableType: issuable.human_class_name }
+ - confirm_body = _('You’re about to permanently delete the %{issuableType} ‘%{strongOpen}%{issuableTitle}%{strongClose}’. To avoid data loss, consider %{strongOpen}closing this %{issuableType}%{strongClose} instead. Once deleted, it cannot be undone or recovered.') % { issuableType: issuable.human_class_name, issuableTitle: issuable.title, strongOpen: '<strong>', strongClose: '</strong>' }
+ - confirm_primary_btn_text = _('Delete %{issuableType}') % { issuableType: issuable.human_class_name }
+ = link_to _('Delete'), polymorphic_path([@project, issuable], params: { destroy_confirm: true }), data: { title: confirm_title, confirm: confirm_body, is_html_message: true, confirm_btn_variant: 'danger'}, method: :delete, class: 'btn gl-button btn-danger btn-danger-secondary gl-float-right js-reset-autosave', "aria-label": confirm_primary_btn_text
- if issuable.respond_to?(:issue_type)
= form.hidden_field :issue_type
diff --git a/app/views/shared/issuable/_milestone_dropdown.html.haml b/app/views/shared/issuable/_milestone_dropdown.html.haml
index 58108ceeb76..a02d2851c4c 100644
--- a/app/views/shared/issuable/_milestone_dropdown.html.haml
+++ b/app/views/shared/issuable/_milestone_dropdown.html.haml
@@ -1,19 +1,11 @@
+- name = local_assigns.fetch(:name, nil)
- project = @target_project || @project
-- extra_class = extra_class || ''
-- show_menu_above = show_menu_above || false
- selected = local_assigns.fetch(:selected, nil)
-- selected_text = selected.try(:title) || params[:milestone_title]
-- dropdown_title = local_assigns.fetch(:dropdown_title, _('Filter by milestone'))
-- if selected.present? || params[:milestone_title].present?
- = hidden_field_tag(name, name == :milestone_title ? selected_text : selected.id)
-= dropdown_tag(milestone_dropdown_label(selected_text), options: { title: dropdown_title, toggle_class: "js-milestone-select js-filter-submit #{extra_class}", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", dropdown_qa_selector: "issuable_milestone_dropdown_content",
- placeholder: _('Search milestones'), footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected_text, project_id: project.try(:id), default_label: _('Milestone'), qa_selector: "issuable_milestone_dropdown", testid: "issuable-milestone-dropdown" } }) do
- - if project
- %ul.dropdown-footer-list
- %li
- = link_to project_milestones_path(project) do
- - if can? current_user, :admin_milestone, project
- = _('Manage milestones')
- - else
- = _('View milestones')
+.js-milestone-dropdown-root{ data: { can_admin_milestone: can?(current_user, :admin_milestone, project),
+ full_path: project.full_path,
+ input_name: name,
+ milestone_id: selected.try(:id),
+ milestone_title: selected.try(:title),
+ project_milestones_path: project_milestones_path(project),
+ workspace_type: Namespaces::ProjectNamespace.sti_name.downcase } }
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 4199b7e870b..0fd128df997 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -17,14 +17,14 @@
%a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", class: "#{'gl-display-block' if moved_sidebar_enabled}", href: "#", "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
- .js-issuable-todo{ data: { project_path: issuable_sidebar[:project_full_path], iid: issuable_sidebar[:iid], id: issuable_sidebar[:id] } }
+ .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|
- .block.assignee.qa-assignee-block{ class: "#{'gl-mt-3' if !signed_in && moved_sidebar_enabled}", data: { qa_selector: 'assignee_block_container' } }
+ .block.assignee{ class: "#{'gl-mt-3' if !signed_in && moved_sidebar_enabled}", data: { qa_selector: 'assignee_block_container' } }
= render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees, signed_in: signed_in
- if issuable_sidebar[:supports_severity]
- #js-severity
+ .js-sidebar-severity-root
- if reviewers
.block.reviewer{ data: { qa_selector: 'reviewers_block_container' } }
@@ -32,17 +32,17 @@
- if issuable_sidebar[:supports_escalation]
.block.escalation-status{ data: { testid: 'escalation_status_container' } }
- #js-escalation-status{ data: { can_update: issuable_sidebar.dig(:current_user, :can_update_escalation_status).to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } }
+ .js-sidebar-escalation-status-root{ data: { can_update: issuable_sidebar.dig(:current_user, :can_update_escalation_status).to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } }
= render_if_exists 'shared/issuable/sidebar_escalation_policy', issuable_sidebar: issuable_sidebar
- if @project.group.present?
= render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type
- .js-sidebar-labels{ data: sidebar_labels_data(issuable_sidebar, @project) }
+ .js-sidebar-labels-widget-root{ data: sidebar_labels_data(issuable_sidebar, @project) }
- if issuable_sidebar[:supports_milestone]
.block.milestone{ :class => ("gl-border-b-0!" if in_group_context_with_iterations), data: { qa_selector: 'milestone_block', testid: 'sidebar-milestones' } }
- .js-milestone-select{ data: { can_edit: can_edit_issuable.to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } }
+ .js-sidebar-milestone-widget-root{ data: { can_edit: can_edit_issuable.to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } }
- if in_group_context_with_iterations
.block.gl-collapse-empty{ data: { qa_selector: 'iteration_container', testid: 'iteration_container' } }<
@@ -50,40 +50,40 @@
- if issuable_sidebar[:show_crm_contacts]
.block.contact
- #js-issue-crm-contacts{ data: { issue_id: issuable_sidebar[:id], group_issues_path: issues_group_path(@project.group) } }
+ .js-sidebar-crm-contacts-root{ data: { issue_id: issuable_sidebar[:id], group_issues_path: issues_group_path(@project.group) } }
= render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar, can_edit: can_edit_issuable.to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid]
- if issuable_sidebar.has_key?(:due_date)
- #js-due-date-entry-point
+ .js-sidebar-due-date-widget-root
- if issuable_sidebar[:supports_time_tracking]
- #issuable-time-tracker.block
+ .js-sidebar-time-tracking-root.block
// Fallback while content is loading
.title.hide-collapsed
= _('Time tracking')
= gl_loading_icon(inline: true)
- if issuable_sidebar.dig(:features_available, :health_status)
- .js-sidebar-health-status-entry-point{ data: sidebar_status_data(issuable_sidebar, @project) }
+ .js-sidebar-health-status-widget-root{ data: sidebar_status_data(issuable_sidebar, @project) }
- if issuable_sidebar.has_key?(:confidential)
-# haml-lint:disable InlineJavaScript
%script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe
- #js-confidential-entry-point
+ .js-sidebar-confidential-widget-root
= render_if_exists 'shared/issuable/sidebar_cve_id_request', issuable_sidebar: issuable_sidebar
- if !moved_sidebar_enabled
- #js-lock-entry-point
+ .js-sidebar-lock-root
- if signed_in
- .js-sidebar-subscriptions-entry-point
+ .js-sidebar-subscriptions-widget-root
- .js-sidebar-participants-entry-point
+ .js-sidebar-participants-widget-root
.block.with-sub-blocks
- if !moved_sidebar_enabled
- #js-reference-entry-point
+ .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
@@ -95,7 +95,7 @@
- if show_forwarding_email
.block
- #issuable-copy-email
+ .js-sidebar-copy-email-root
- if issuable_sidebar.dig(:current_user, :can_move)
.block.js-sidebar-move-issue-block
.sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body', boundary: 'viewport' }, title: _('Move issue') }
diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml
index 62221fb8218..8ca30d7ca97 100644
--- a/app/views/shared/issuable/_sidebar_assignees.html.haml
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -1,7 +1,7 @@
- issuable_type = issuable_sidebar[:type]
- dropdown_options = assignees_dropdown_options(issuable_type)
-#js-vue-sidebar-assignees{ data: { field: issuable_type,
+.js-sidebar-assignees-root{ data: { field: issuable_type,
signed_in: signed_in,
max_assignees: dropdown_options[:data][:"max-select"],
directly_invite_members: can_admin_project_member?(@project) } }
@@ -39,7 +39,7 @@
- data[:multi_select] = true
- data['dropdown-title'] = title
- data['dropdown-header'] = dropdown_options[:data][:'dropdown-header']
- - data['max-select'] = dropdown_max_select(dropdown_options[:data], :limit_assignees_per_issuable)
+ - data['max-select'] = dropdown_max_select(dropdown_options[:data], nil)
- options[:data].merge!(data)
= render 'shared/issuable/sidebar_user_dropdown',
diff --git a/app/views/shared/issuable/_sidebar_reviewers.html.haml b/app/views/shared/issuable/_sidebar_reviewers.html.haml
index 771db8af6a8..4df393eeb67 100644
--- a/app/views/shared/issuable/_sidebar_reviewers.html.haml
+++ b/app/views/shared/issuable/_sidebar_reviewers.html.haml
@@ -1,16 +1,12 @@
- issuable_type = issuable_sidebar[:type]
-#js-vue-sidebar-reviewers{ data: { field: issuable_type, signed_in: signed_in } }
+.js-sidebar-reviewers-root{ data: { field: issuable_type, signed_in: signed_in } }
.title.hide-collapsed
= _('Reviewers')
= gl_loading_icon(inline: true)
.selectbox.hide-collapsed
- - if reviewers.none?
- = hidden_field_tag "#{issuable_type}[reviewer_ids][]", 0, id: nil
- - else
- - reviewers.each do |reviewer|
- = hidden_field_tag "#{issuable_type}[reviewer_ids][]", reviewer.id, id: nil, data: reviewer_sidebar_data(reviewer, merge_request: @merge_request)
+ .js-reviewers-inputs
- options = { toggle_class: 'js-reviewer-search js-author-search',
title: _('Request review from'),
@@ -32,10 +28,10 @@
- dropdown_options = reviewers_dropdown_options(issuable_type)
- title = dropdown_options[:title]
- options[:toggle_class] += ' js-multiselect js-save-user-data'
- - data = { field_name: "#{issuable_type}[reviewer_ids][]" }
- - data[:multi_select] = true
+ - data = { multi_select: true }
- data['dropdown-title'] = title
- data['dropdown-header'] = dropdown_options[:data][:'dropdown-header']
+ - data[:suggested_reviewers_help_path] = dropdown_options[:data][:suggested_reviewers_help_path]
- data[:suggested_reviewers_header] = dropdown_options[:data][:suggested_reviewers_header]
- data[:all_members_header] = dropdown_options[:data][:all_members_header]
- data[:show_suggested] = dropdown_options[:data][:show_suggested]
diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml
index 76469b34832..9603178f7de 100644
--- a/app/views/shared/issuable/form/_metadata.html.haml
+++ b/app/views/shared/issuable/form/_metadata.html.haml
@@ -35,7 +35,7 @@
= form.label :milestone_id, _('Milestone'), class: "col-12"
.col-12
.issuable-form-select-holder
- = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: _('Select milestone')
+ = 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"
@@ -54,3 +54,4 @@
.col-12
.issuable-form-select-holder
= form.gitlab_ui_datepicker :due_date, placeholder: _('Select due date'), autocomplete: 'off', id: "issuable-due-date"
+ = render_if_exists "shared/issuable/form/iteration", form: form, group: project.group
diff --git a/app/views/shared/issue_type/_emoji_block.html.haml b/app/views/shared/issue_type/_emoji_block.html.haml
index a5c71fb1d24..7eb3c0f5c9f 100644
--- a/app/views/shared/issue_type/_emoji_block.html.haml
+++ b/app/views/shared/issue_type/_emoji_block.html.haml
@@ -1,8 +1,8 @@
- api_awards_path = local_assigns.fetch(:api_awards_path, nil)
-.content-block.emoji-block.emoji-block-sticky
+.emoji-block.emoji-block-sticky
.row.gl-m-0.gl-justify-content-space-between
.js-noteable-awards
= render 'award_emoji/awards_block', awardable: issuable, inline: true, api_awards_path: api_awards_path
- .new-branch-col.gl-font-size-0
+ .new-branch-col.gl-font-size-0.gl-my-2
= render 'new_branch' if show_new_branch_button?
diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml
index c6932d49d33..9ef4b9e084d 100644
--- a/app/views/shared/labels/_form.html.haml
+++ b/app/views/shared/labels/_form.html.haml
@@ -29,8 +29,10 @@
= f.submit _('Save changes'), class: 'js-save-button gl-mr-2', pajamas_button: true
- else
= f.submit _('Create label'), class: 'js-save-button gl-mr-2', data: { qa_selector: 'label_create_button' }, pajamas_button: true
- = link_to _('Cancel'), back_path, class: 'btn gl-button btn-default btn-cancel gl-mr-2'
+ = render Pajamas::ButtonComponent.new(href: back_path) do
+ = _('Cancel')
+
- if @label.persisted?
- presented_label = @label.present
- %button.btn.btn-danger.gl-button.btn-danger-secondary.js-delete-label-modal-button{ type: 'button', data: { label_name: presented_label.name, subject_name: presented_label.subject_name, destroy_path: presented_label.destroy_path } }
- %span.gl-button-text= _('Delete')
+ = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, button_options: { class: 'js-delete-label-modal-button', data: { label_name: presented_label.name, subject_name: presented_label.subject_name, destroy_path: presented_label.destroy_path } }) do
+ = _('Delete')
diff --git a/app/views/shared/members/_access_request_links.html.haml b/app/views/shared/members/_access_request_links.html.haml
index 7af946377be..0b38b9d7945 100644
--- a/app/views/shared/members/_access_request_links.html.haml
+++ b/app/views/shared/members/_access_request_links.html.haml
@@ -8,9 +8,10 @@
data: { confirm: leave_confirmation_message(source), confirm_btn_variant: 'danger', qa_selector: 'leave_group_link' },
class: 'js-leave-link'
- elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord
- = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]),
- method: :delete,
- data: { confirm: remove_member_message(requester) }
+ - if can?(current_user, :withdraw_member_access_request, requester)
+ = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]),
+ method: :delete,
+ data: { confirm: remove_member_message(requester) }
- elsif source.request_access_enabled && can?(current_user, :request_access, source)
= link_to _('Request Access'), polymorphic_path([:request_access, source, :members]),
method: :post
diff --git a/app/views/shared/milestones/_delete_button.html.haml b/app/views/shared/milestones/_delete_button.html.haml
index 8a709a36835..432d2efc36e 100644
--- a/app/views/shared/milestones/_delete_button.html.haml
+++ b/app/views/shared/milestones/_delete_button.html.haml
@@ -1,11 +1,7 @@
- milestone_url = @milestone.project_milestone? ? project_milestone_path(@project, @milestone) : group_milestone_path(@group, @milestone)
-%button.js-delete-milestone-button.btn.gl-button.btn-grouped.btn-danger{ 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 }
+= 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')
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index 6a65909b1c2..cc1965945ac 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -94,7 +94,7 @@
= milestone.issues_visible_to_user(current_user).closed.count
.block
- #issuable-time-tracker{ data: { time_estimate: @milestone.total_time_estimate,
+ .js-sidebar-time-tracking-root{ data: { time_estimate: @milestone.total_time_estimate,
time_spent: @milestone.total_time_spent,
human_time_estimate: @milestone.human_total_time_estimate,
human_time_spent: @milestone.human_total_time_spent,
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index c39dc561801..43cd2ee4c5b 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -32,7 +32,7 @@
- if any_projects?(projects)
- load_pipeline_status(projects) if pipeline_status
- load_max_project_member_accesses(projects) # Prime cache used in shared/projects/project view rendered below
- %ul.projects-list{ class: css_classes }
+ %ul.projects-list.gl-text-secondary.gl-w-full.gl-my-2{ class: css_classes }
- projects.each_with_index do |project, i|
- css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil
= render "shared/projects/project", project: project, skip_namespace: skip_namespace,
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index 81e2e066bd3..908eb2428e8 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -8,102 +8,108 @@
- access = max_project_member_access(project)
- compact_mode = false unless local_assigns[:compact_mode] == true
- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && can_show_last_commit_in_list?(project)
-- css_class = '' unless local_assigns[:css_class]
-- css_class += " gl-display-flex!"
+- css_class = "gl-sm-display-flex gl-align-items-center gl-vertical-align-middle!" if project.description.blank? && !show_last_commit_as_description
- cache_key = project_list_cache_key(project, pipeline_status: pipeline_status)
- updated_tooltip = time_ago_with_tooltip(project.last_activity_date)
- show_pipeline_status_icon = pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project)
- last_pipeline = project.last_pipeline if show_pipeline_status_icon
-- css_controls_class = compact_mode ? [] : ["flex-lg-row", "justify-content-lg-between"]
-- css_controls_class << "with-pipeline-status" if show_pipeline_status_icon && last_pipeline.present?
-- avatar_container_class = project.creator && use_creator_avatar ? '' : 'rect-avatar'
+- 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"
-%li.project-row.gl-align-items-center{ class: css_class }
+%li.project-row
= cache(cache_key) do
- if avatar
- .flex-grow-0.flex-shrink-0{ class: avatar_container_class }
+ .project-cell.gl-w-11
= link_to project_path(project), class: dom_class(project) do
- if project.creator && use_creator_avatar
= render Pajamas::AvatarComponent.new(project.creator, size: 48, alt: '', class: 'gl-mr-5')
- else
= render Pajamas::AvatarComponent.new(project, size: 48, alt: '', class: 'gl-mr-5')
- .project-details.d-sm-flex.flex-sm-fill.align-items-center{ data: { qa_selector: 'project_content', qa_project_name: project.name } }
- .flex-wrapper
- .d-flex.align-items-center.flex-wrap.project-title
- %h2.d-flex.gl-mt-3
- = link_to project_path(project), class: 'text-plain js-prefetch-document' do
- %span.project-full-name.gl-mr-3><
- %span.namespace-name
- - if project.namespace && !skip_namespace
- = project.namespace.human_name
- \/
- %span.project-name<
- = project.name
+ .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
+ %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
+ - if project.namespace && !skip_namespace
+ = project.namespace.human_name
+ \/
+ %span.project-name<
+ = project.name
- %span.metadata-info.visibility-icon.gl-mr-3.gl-mt-3.text-secondary.has-tooltip{ data: { container: 'body', placement: 'top' }, title: visibility_icon_description(project) }
- = visibility_level_icon(project.visibility_level)
+ %span.gl-mr-3.has-tooltip{ data: { container: 'body', placement: 'top' }, title: visibility_icon_description(project) }
+ = visibility_level_icon(project.visibility_level)
- - if explore_projects_tab? && project_license_name(project)
- %span.metadata-info.d-inline-flex.align-items-center.gl-mr-3.gl-mt-3
- = sprite_icon('scale', size: 14, css_class: 'gl-mr-2')
- = project_license_name(project)
+ - if explore_projects_tab? && project_license_name(project)
+ %span.gl-display-inline-flex.gl-align-items-center.gl-mr-3
+ = sprite_icon('scale', size: 14, css_class: 'gl-mr-2')
+ = project_license_name(project)
- - if !explore_projects_tab? && access&.nonzero?
- -# haml-lint:disable UnnecessaryStringOutput
- = ' ' # prevent haml from eating the space between elements
- .metadata-info.gl-mt-3
- %span.user-access-role.gl-display-block{ data: { qa_selector: 'user_role_content' } }= localized_project_human_access(access)
+ - if !explore_projects_tab? && access&.nonzero?
+ -# haml-lint:disable UnnecessaryStringOutput
+ = ' ' # prevent haml from eating the space between elements
+ %span.user-access-role.gl-display-block.gl-m-0{ data: { qa_selector: 'user_role_content' } }= Gitlab::Access.human_access(access)
- - if !explore_projects_tab?
- .metadata-info.gl-mt-3
- = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: project
+ - if !explore_projects_tab?
+ = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: project
- - if show_last_commit_as_description
- .description.d-none.d-sm-block.gl-mr-3
- = link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message")
- - elsif project.description.present?
- .description.d-none.d-sm-block.gl-mr-3
- = markdown_field(project, :description)
+ - if show_last_commit_as_description
+ .description.gl-display-none.gl-sm-display-block.gl-overflow-hidden.gl-mr-3.gl-mt-2
+ = 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
+ = markdown_field(project, :description)
- - if project.topics.any?
- .gl-mt-2
- = render "shared/projects/topics", project: project.present(current_user: current_user)
+ - if project.topics.any?
+ .gl-mt-2
+ = render "shared/projects/topics", project: project.present(current_user: current_user)
- = render_if_exists 'shared/projects/removed', project: project
+ = render_if_exists 'shared/projects/removed', project: project
- .controls.d-flex.flex-sm-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0.text-secondary{ class: css_controls_class.join(" ") }
- .icon-container.d-flex.align-items-center
+ .gl-display-flex.gl-mt-3{ class: "#{css_class} gl-sm-display-none!" }
+ .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
+ %span.icon-wrapper.pipeline-status.gl-mr-5
= render 'ci/status/icon', status: last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path
= render_if_exists 'shared/projects/archived', project: project
- if stars
- = link_to project_starrers_path(project),
- class: "d-flex align-items-center icon-wrapper stars has-tooltip",
- title: _('Stars'), data: { container: 'body', placement: 'top' } do
- = sprite_icon('star', size: 14, css_class: 'gl-mr-2')
- = number_with_delimiter(project.star_count)
- - if forks
- = link_to project_forks_path(project),
- class: "align-items-center icon-wrapper forks has-tooltip",
- title: _('Forks'), data: { container: 'body', placement: 'top' } do
- = sprite_icon('fork', size: 14, css_class: 'gl-mr-2')
- = number_with_delimiter(project.forks_count)
- - if show_merge_request_count?(disabled: !merge_requests, compact_mode: compact_mode)
- = link_to project_merge_requests_path(project),
- class: "d-none d-xl-flex align-items-center icon-wrapper merge-requests has-tooltip",
- title: _('Merge requests'), data: { container: 'body', placement: 'top' } do
- = sprite_icon('git-merge', size: 14, css_class: 'gl-mr-2')
- = number_with_delimiter(project.open_merge_requests_count)
- - if show_issue_count?(disabled: !issues, compact_mode: compact_mode)
- = link_to project_issues_path(project),
- class: "d-none d-xl-flex align-items-center icon-wrapper issues has-tooltip",
- title: _('Issues'), data: { container: 'body', placement: 'top' } do
- = sprite_icon('issues', size: 14, css_class: 'gl-mr-2')
- = number_with_delimiter(project.open_issues_count)
- .updated-note
+ = 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
%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 }
+ - 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
+ = render 'ci/status/icon', status: last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path
+
+ = render_if_exists 'shared/projects/archived', project: project
+ - if stars
+ = 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)
+ - if forks
+ = link_to project_forks_path(project), class: "#{css_metadata_classes} forks", title: _('Forks'), data: { container: 'body', placement: 'top' } do
+ = sprite_icon('fork', size: 14, css_class: 'gl-mr-2')
+ = badge_count(project.forks_count)
+ - if show_merge_request_count?(disabled: !merge_requests, compact_mode: compact_mode)
+ = link_to project_merge_requests_path(project), class: "#{css_metadata_classes} merge-requests", title: _('Merge requests'), data: { container: 'body', placement: 'top' } do
+ = sprite_icon('git-merge', size: 14, css_class: 'gl-mr-2')
+ = badge_count(project.open_merge_requests_count)
+ - if show_issue_count?(disabled: !issues, compact_mode: compact_mode)
+ = 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
+ %span
+ = _('Updated')
+ = updated_tooltip
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index c95e63bdc83..ecb736dac4f 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -10,21 +10,25 @@
= s_('Webhooks|URL must be percent-encoded if it contains one or more special characters.')
.form-group
= form.label :token, s_('Webhooks|Secret token'), class: 'label-bold'
- = form.text_field :token, class: 'form-control gl-form-input', placeholder: ''
+ = form.password_field :token, value: hook.masked_token, autocomplete: 'new-password', class: 'form-control gl-form-input'
%p.form-text.text-muted
- code_start = '<code>'.html_safe
- code_end = '</code>'.html_safe
- = s_('Webhooks|Used to validate received payloads. Sent with the request in the %{code_start}X-Gitlab-Token HTTP%{code_end} header.').html_safe % { code_start: code_start, code_end: code_end }
+ = s_('Webhooks|Used to validate received payloads. Sent with the request in the %{code_start}X-Gitlab-Token%{code_end} HTTP header.').html_safe % { code_start: code_start, code_end: code_end }
.form-group
= form.label :url, s_('Webhooks|Trigger'), class: 'label-bold'
%ul.list-unstyled
%li.gl-pb-5
- = form.gitlab_ui_checkbox_component :push_events, s_('Webhooks|Push events')
- .gl-pl-6
- = form.text_field :push_events_branch_filter, class: 'form-control gl-form-input',
- placeholder: 'Branch name or wildcard pattern to trigger on (leave blank for all)'
- %p.form-text.text-muted.custom-control
- = s_('Webhooks|Push to the repository.')
+ - if Feature.enabled?(:enhanced_webhook_support_regex)
+ - is_new_hook = hook.id.nil?
+ .js-vue-push-events{ data: { push_events: hook.push_events.to_s, strategy: hook.branch_filter_strategy, is_new_hook: is_new_hook.to_s, push_events_branch_filter: hook.push_events_branch_filter } }
+ - else
+ = form.gitlab_ui_checkbox_component :push_events, s_('Webhooks|Push events')
+ .gl-pl-6
+ = form.text_field :push_events_branch_filter, class: 'form-control gl-form-input',
+ placeholder: 'Branch name or wildcard pattern to trigger on (leave blank for all)'
+ %p.form-text.text-muted.custom-control
+ = s_('Webhooks|Push to the repository.')
%li.gl-pb-5
= form.gitlab_ui_checkbox_component :tag_push_events,
s_('Webhooks|Tag push events'),
diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml
index 47ccc449e1b..bb43c3c6274 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/snippets/show.html.haml
@@ -12,7 +12,7 @@
- content_for :prefetch_asset_tags do
- webpack_preload_asset_tag('monaco', prefetch: true)
-#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet), 'can-report-spam': @snippet.submittable_as_spam_by?(current_user).to_s } }
+#js-snippet-view{ data: { 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet), 'can-report-spam': @snippet.submittable_as_spam_by?(current_user).to_s } }
.row-content-block.top-block.content-component-block.gl-px-0.gl-py-2
= render 'award_emoji/awards_block', awardable: @snippet, inline: true
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 952023b3745..7cef87ba19f 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -14,33 +14,31 @@
.cover-block.user-cover-block{ class: [('border-bottom' if profile_tabs.empty?)] }
= render layout: 'users/cover_controls' do
- if @user == current_user
- = link_to profile_path, class: link_classes + 'btn gl-button btn-default btn-icon has-tooltip',
- title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile', data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
- = sprite_icon('pencil')
+ = 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
- if @user.abuse_report
- %button{ class: link_classes + 'btn gl-button btn-danger btn-icon', title: s_('UserProfile|Already reported for abuse'),
- data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } }>
- = sprite_icon('error')
+ = render Pajamas::ButtonComponent.new(variant: :danger,
+ icon: 'error',
+ button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Already reported for abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }})
- else
- = link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referer), class: link_classes + 'btn gl-button btn-default btn-icon',
- title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
- = sprite_icon('error')
+ = render Pajamas::ButtonComponent.new(href: new_abuse_report_path(user_id: @user.id, ref_url: request.referer),
+ icon: 'error',
+ button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }})
- verified_gpg_keys = @user.gpg_keys.select(&:verified?)
- if verified_gpg_keys.any?
- = link_to user_gpg_keys_path,
- class: link_classes + 'btn btn-default btn-md gl-button btn-icon has-tooltip',
- title: n_('View public GPG key', 'View public GPG keys', verified_gpg_keys.length),
- data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
- = sprite_icon('key', css_class: 'gl-button-icon gl-icon')
+ = 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)
- = link_to user_path(@user, rss_url_options), class: link_classes + 'btn gl-button btn-default btn-icon has-tooltip',
- title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
- = sprite_icon('rss')
+ = 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?
- = link_to [:admin, @user], class: link_classes + 'btn gl-button btn-default btn-icon', title: s_('UserProfile|View user in admin area'),
- data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = sprite_icon('user')
+ = 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
@@ -58,12 +56,12 @@
- if @user.blocked? || !@user.confirmed?
.user-info
- .cover-title
+ %h1.cover-title
= user_display_name(@user)
= render "users/profile_basic_info"
- else
.user-info
- .cover-title{ itemprop: 'name' }
+ %h1.cover-title{ itemprop: 'name' }
= @user.name
- if @user.pronouns.present?
%span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle
@@ -133,7 +131,6 @@
.profile-user-bio
= @user.bio
-
- unless profile_tabs.empty?
.scrolling-tabs-container
.fade-left= sprite_icon('chevron-lg-left', size: 12)
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index a0f6da57f9e..b9168a65764 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -147,6 +147,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: container_repository_delete:container_registry_delete_container_repository
+ :worker_name: ContainerRegistry::DeleteContainerRepositoryWorker
+ :feature_category: :container_registry
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:admin_email
:worker_name: AdminEmailWorker
:feature_category: :source_code_management
@@ -282,6 +291,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: cronjob:container_registry_cleanup
+ :worker_name: ContainerRegistry::CleanupWorker
+ :feature_category: :container_registry
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:container_registry_migration_enqueuer
:worker_name: ContainerRegistry::Migration::EnqueuerWorker
:feature_category: :container_registry
@@ -779,7 +797,7 @@
:tags: []
- :name: cronjob:users_deactivate_dormant_users
:worker_name: Users::DeactivateDormantUsersWorker
- :feature_category: :utilization
+ :feature_category: :subscription_cost_management
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -1146,6 +1164,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: github_importer:github_import_pull_requests_import_review_request
+ :worker_name: Gitlab::GithubImport::PullRequests::ImportReviewRequestWorker
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :cpu
+ :weight: 1
+ :idempotent: false
+ :tags: []
- :name: github_importer:github_import_refresh_import_jid
:worker_name: Gitlab::GithubImport::RefreshImportJidWorker
:feature_category: :importers
@@ -1245,6 +1272,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: github_importer:github_import_stage_import_pull_requests_review_requests
+ :worker_name: Gitlab::GithubImport::Stage::ImportPullRequestsReviewRequestsWorker
+ :feature_category: :importers
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: false
+ :tags: []
- :name: github_importer:github_import_stage_import_pull_requests_reviews
:worker_name: Gitlab::GithubImport::Stage::ImportPullRequestsReviewsWorker
:feature_category: :importers
@@ -2694,6 +2730,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: merge_requests_delete_branch
+ :worker_name: MergeRequests::DeleteBranchWorker
+ :feature_category: :source_code_management
+ :has_external_dependencies: false
+ :urgency: :high
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: merge_requests_delete_source_branch
:worker_name: MergeRequests::DeleteSourceBranchWorker
:feature_category: :source_code_management
@@ -3236,7 +3281,7 @@
:tags: []
- :name: update_highest_role
:worker_name: UpdateHighestRoleWorker
- :feature_category: :utilization
+ :feature_category: :subscription_usage_reports
:has_external_dependencies: false
:urgency: :high
:resource_boundary: :unknown
diff --git a/app/workers/authorized_keys_worker.rb b/app/workers/authorized_keys_worker.rb
index 039fe629a61..77f2ed5f495 100644
--- a/app/workers/authorized_keys_worker.rb
+++ b/app/workers/authorized_keys_worker.rb
@@ -7,8 +7,6 @@ class AuthorizedKeysWorker
sidekiq_options retry: 3
- PERMITTED_ACTIONS = %w[add_key remove_key].freeze
-
feature_category :source_code_management
urgency :high
weight 2
diff --git a/app/workers/bulk_imports/entity_worker.rb b/app/workers/bulk_imports/entity_worker.rb
index ada3210624c..d23d57c33ab 100644
--- a/app/workers/bulk_imports/entity_worker.rb
+++ b/app/workers/bulk_imports/entity_worker.rb
@@ -12,13 +12,18 @@ module BulkImports
worker_has_external_dependencies!
def perform(entity_id, current_stage = nil)
+ @entity = ::BulkImports::Entity.find(entity_id)
+
if stage_running?(entity_id, current_stage)
logger.info(
structured_payload(
bulk_import_entity_id: entity_id,
- bulk_import_id: bulk_import_id(entity_id),
+ bulk_import_id: entity.bulk_import_id,
+ bulk_import_entity_type: entity.source_type,
+ source_full_path: entity.source_full_path,
current_stage: current_stage,
message: 'Stage running',
+ source_version: source_version,
importer: 'gitlab_migration'
)
)
@@ -29,9 +34,12 @@ module BulkImports
logger.info(
structured_payload(
bulk_import_entity_id: entity_id,
- bulk_import_id: bulk_import_id(entity_id),
+ bulk_import_id: entity.bulk_import_id,
+ bulk_import_entity_type: entity.source_type,
+ source_full_path: entity.source_full_path,
current_stage: current_stage,
message: 'Stage starting',
+ source_version: source_version,
importer: 'gitlab_migration'
)
)
@@ -44,23 +52,34 @@ module BulkImports
)
end
rescue StandardError => e
- logger.error(
- structured_payload(
+ log_exception(e,
+ {
bulk_import_entity_id: entity_id,
- bulk_import_id: bulk_import_id(entity_id),
+ bulk_import_id: entity.bulk_import_id,
+ bulk_import_entity_type: entity.source_type,
+ source_full_path: entity.source_full_path,
current_stage: current_stage,
- message: e.message,
+ message: 'Entity failed',
+ source_version: source_version,
importer: 'gitlab_migration'
- )
+ }
)
Gitlab::ErrorTracking.track_exception(
- e, bulk_import_entity_id: entity_id, bulk_import_id: bulk_import_id(entity_id), importer: 'gitlab_migration'
+ e,
+ bulk_import_entity_id: entity_id,
+ bulk_import_id: entity.bulk_import_id,
+ bulk_import_entity_type: entity.source_type,
+ source_full_path: entity.source_full_path,
+ source_version: source_version,
+ importer: 'gitlab_migration'
)
end
private
+ attr_reader :entity
+
def stage_running?(entity_id, stage)
return unless stage
@@ -71,12 +90,18 @@ module BulkImports
BulkImports::Tracker.next_pipeline_trackers_for(entity_id).update(status_event: 'enqueue')
end
- def bulk_import_id(entity_id)
- @bulk_import_id ||= Entity.find(entity_id).bulk_import_id
+ def source_version
+ entity.bulk_import.source_version_info.to_s
end
def logger
@logger ||= Gitlab::Import::Logger.build
end
+
+ def log_exception(exception, payload)
+ Gitlab::ExceptionLogFormatter.format!(exception, payload)
+
+ logger.error(structured_payload(payload))
+ end
end
end
diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb
index a57071ddcf1..1a5f6250429 100644
--- a/app/workers/bulk_imports/export_request_worker.rb
+++ b/app/workers/bulk_imports/export_request_worker.rb
@@ -22,7 +22,19 @@ module BulkImports
if e.retriable?(entity)
retry_request(e, entity)
else
- log_export_failure(e, entity)
+ log_exception(e,
+ {
+ bulk_import_entity_id: entity.id,
+ bulk_import_id: entity.bulk_import_id,
+ bulk_import_entity_type: entity.source_type,
+ source_full_path: entity.source_full_path,
+ message: "Request to export #{entity.source_type} failed",
+ source_version: entity.bulk_import.source_version_info.to_s,
+ importer: 'gitlab_migration'
+ }
+ )
+
+ BulkImports::Failure.create(failure_attributes(e, entity))
entity.fail_op!
end
@@ -41,22 +53,7 @@ module BulkImports
)
end
- def log_export_failure(exception, entity)
- Gitlab::Import::Logger.error(
- structured_payload(
- log_attributes(exception, entity).merge(
- bulk_import_id: entity.bulk_import_id,
- bulk_import_entity_type: entity.source_type,
- message: "Request to export #{entity.source_type} failed",
- importer: 'gitlab_migration'
- )
- )
- )
-
- BulkImports::Failure.create(log_attributes(exception, entity))
- end
-
- def log_attributes(exception, entity)
+ def failure_attributes(exception, entity)
{
bulk_import_entity_id: entity.id,
pipeline_class: 'ExportRequestWorker',
@@ -84,15 +81,16 @@ module BulkImports
::GlobalID.parse(response.dig(*query.data_path, 'id')).model_id
rescue StandardError => e
- Gitlab::Import::Logger.error(
- structured_payload(
- log_attributes(e, entity).merge(
- message: 'Failed to fetch source entity id',
- bulk_import_id: entity.bulk_import_id,
- bulk_import_entity_type: entity.source_type,
- importer: 'gitlab_migration'
- )
- )
+ log_exception(e,
+ {
+ message: 'Failed to fetch source entity id',
+ bulk_import_entity_id: entity.id,
+ bulk_import_id: entity.bulk_import_id,
+ bulk_import_entity_type: entity.source_type,
+ source_full_path: entity.source_full_path,
+ source_version: entity.bulk_import.source_version_info.to_s,
+ importer: 'gitlab_migration'
+ }
)
nil
@@ -107,18 +105,29 @@ module BulkImports
end
def retry_request(exception, entity)
- Gitlab::Import::Logger.error(
- structured_payload(
- log_attributes(exception, entity).merge(
- message: 'Retrying export request',
- bulk_import_id: entity.bulk_import_id,
- bulk_import_entity_type: entity.source_type,
- importer: 'gitlab_migration'
- )
- )
+ log_exception(exception,
+ {
+ message: 'Retrying export request',
+ bulk_import_entity_id: entity.id,
+ bulk_import_id: entity.bulk_import_id,
+ bulk_import_entity_type: entity.source_type,
+ source_full_path: entity.source_full_path,
+ source_version: entity.bulk_import.source_version_info.to_s,
+ importer: 'gitlab_migration'
+ }
)
self.class.perform_in(2.seconds, entity.id)
end
+
+ def logger
+ @logger ||= Gitlab::Import::Logger.build
+ end
+
+ def log_exception(exception, payload)
+ Gitlab::ExceptionLogFormatter.format!(exception, payload)
+
+ logger.error(structured_payload(payload))
+ end
end
end
diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb
index 6d314774cff..5716f6e3f31 100644
--- a/app/workers/bulk_imports/pipeline_worker.rb
+++ b/app/workers/bulk_imports/pipeline_worker.rb
@@ -17,24 +17,34 @@ module BulkImports
.find_by_id(pipeline_tracker_id)
if pipeline_tracker.present?
+ @entity = @pipeline_tracker.entity
+
logger.info(
structured_payload(
- bulk_import_entity_id: pipeline_tracker.entity.id,
- bulk_import_id: pipeline_tracker.entity.bulk_import_id,
+ bulk_import_entity_id: entity.id,
+ bulk_import_id: entity.bulk_import_id,
+ bulk_import_entity_type: entity.source_type,
+ source_full_path: entity.source_full_path,
pipeline_name: pipeline_tracker.pipeline_name,
message: 'Pipeline starting',
+ source_version: source_version,
importer: 'gitlab_migration'
)
)
run
else
+ @entity = ::BulkImports::Entity.find(entity_id)
+
logger.error(
structured_payload(
bulk_import_entity_id: entity_id,
- bulk_import_id: bulk_import_id(entity_id),
+ bulk_import_id: entity.bulk_import_id,
+ bulk_import_entity_type: entity.source_type,
+ source_full_path: entity.source_full_path,
pipeline_tracker_id: pipeline_tracker_id,
message: 'Unstarted pipeline not found',
+ source_version: source_version,
importer: 'gitlab_migration'
)
)
@@ -46,10 +56,10 @@ module BulkImports
private
- attr_reader :pipeline_tracker
+ attr_reader :pipeline_tracker, :entity
def run
- return skip_tracker if pipeline_tracker.entity.failed?
+ return skip_tracker if entity.failed?
raise(Pipeline::ExpiredError, 'Pipeline timeout') if job_timeout?
raise(Pipeline::FailedError, "Export from source instance failed: #{export_status.error}") if export_failed?
@@ -65,33 +75,39 @@ module BulkImports
fail_tracker(e)
end
- def bulk_import_id(entity_id)
- @bulk_import_id ||= Entity.find(entity_id).bulk_import_id
+ def source_version
+ entity.bulk_import.source_version_info.to_s
end
def fail_tracker(exception)
pipeline_tracker.update!(status_event: 'fail_op', jid: jid)
- logger.error(
- structured_payload(
- bulk_import_entity_id: pipeline_tracker.entity.id,
- bulk_import_id: pipeline_tracker.entity.bulk_import_id,
+ log_exception(exception,
+ {
+ bulk_import_entity_id: entity.id,
+ bulk_import_id: entity.bulk_import_id,
+ bulk_import_entity_type: entity.source_type,
+ source_full_path: entity.source_full_path,
pipeline_name: pipeline_tracker.pipeline_name,
- message: exception.message,
+ message: 'Pipeline failed',
+ source_version: source_version,
importer: 'gitlab_migration'
- )
+ }
)
Gitlab::ErrorTracking.track_exception(
exception,
- bulk_import_entity_id: pipeline_tracker.entity.id,
- bulk_import_id: pipeline_tracker.entity.bulk_import_id,
+ bulk_import_entity_id: entity.id,
+ bulk_import_id: entity.bulk_import_id,
+ bulk_import_entity_type: entity.source_type,
+ source_full_path: entity.source_full_path,
pipeline_name: pipeline_tracker.pipeline_name,
+ source_version: source_version,
importer: 'gitlab_migration'
)
BulkImports::Failure.create(
- bulk_import_entity_id: context.entity.id,
+ bulk_import_entity_id: entity.id,
pipeline_class: pipeline_tracker.pipeline_name,
pipeline_step: 'pipeline_worker_run',
exception_class: exception.class.to_s,
@@ -109,7 +125,7 @@ module BulkImports
delay,
pipeline_tracker.id,
pipeline_tracker.stage,
- pipeline_tracker.entity.id
+ entity.id
)
end
@@ -128,7 +144,7 @@ module BulkImports
def job_timeout?
return false unless file_extraction_pipeline?
- (Time.zone.now - pipeline_tracker.entity.created_at) > Pipeline::NDJSON_EXPORT_TIMEOUT
+ (Time.zone.now - entity.created_at) > Pipeline::NDJSON_EXPORT_TIMEOUT
end
def export_failed?
@@ -150,14 +166,17 @@ module BulkImports
end
def retry_tracker(exception)
- logger.error(
- structured_payload(
- bulk_import_entity_id: pipeline_tracker.entity.id,
- bulk_import_id: pipeline_tracker.entity.bulk_import_id,
+ log_exception(exception,
+ {
+ bulk_import_entity_id: entity.id,
+ bulk_import_id: entity.bulk_import_id,
+ bulk_import_entity_type: entity.source_type,
+ source_full_path: entity.source_full_path,
pipeline_name: pipeline_tracker.pipeline_name,
- message: "Retrying error: #{exception.message}",
+ message: "Retrying pipeline",
+ source_version: source_version,
importer: 'gitlab_migration'
- )
+ }
)
pipeline_tracker.update!(status_event: 'retry', jid: jid)
@@ -168,15 +187,23 @@ module BulkImports
def skip_tracker
logger.info(
structured_payload(
- bulk_import_entity_id: pipeline_tracker.entity.id,
- bulk_import_id: pipeline_tracker.entity.bulk_import_id,
+ bulk_import_entity_id: entity.id,
+ bulk_import_id: entity.bulk_import_id,
+ bulk_import_entity_type: entity.source_type,
+ source_full_path: entity.source_full_path,
pipeline_name: pipeline_tracker.pipeline_name,
message: 'Skipping pipeline due to failed entity',
+ source_version: source_version,
importer: 'gitlab_migration'
)
)
pipeline_tracker.update!(status_event: 'skip', jid: jid)
end
+
+ def log_exception(exception, payload)
+ Gitlab::ExceptionLogFormatter.format!(exception, payload)
+ logger.error(structured_payload(payload))
+ end
end
end
diff --git a/app/workers/cluster_configure_istio_worker.rb b/app/workers/cluster_configure_istio_worker.rb
index 0def66b7381..8bf723d89f7 100644
--- a/app/workers/cluster_configure_istio_worker.rb
+++ b/app/workers/cluster_configure_istio_worker.rb
@@ -1,5 +1,8 @@
# frozen_string_literal: true
+# DEPRECATED
+#
+# To be removed by https://gitlab.com/gitlab-org/gitlab/-/issues/366573
class ClusterConfigureIstioWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
@@ -10,9 +13,5 @@ class ClusterConfigureIstioWorker # rubocop:disable Scalability/IdempotentWorker
worker_has_external_dependencies!
- def perform(cluster_id)
- Clusters::Cluster.find_by_id(cluster_id).try do |cluster|
- Clusters::Kubernetes::ConfigureIstioIngressService.new(cluster: cluster).execute
- end
- end
+ def perform(cluster_id); end
end
diff --git a/app/workers/cluster_install_app_worker.rb b/app/workers/cluster_install_app_worker.rb
index e16e6e9ca71..0c94f8cad6a 100644
--- a/app/workers/cluster_install_app_worker.rb
+++ b/app/workers/cluster_install_app_worker.rb
@@ -1,5 +1,8 @@
# frozen_string_literal: true
+# DEPRECATED
+#
+# To be removed by https://gitlab.com/gitlab-org/gitlab/-/issues/366573
class ClusterInstallAppWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
@@ -12,9 +15,5 @@ class ClusterInstallAppWorker # rubocop:disable Scalability/IdempotentWorker
worker_has_external_dependencies!
loggable_arguments 0
- def perform(app_name, app_id)
- find_application(app_name, app_id) do |app|
- Clusters::Applications::InstallService.new(app).execute
- end
- end
+ def perform(app_name, app_id); end
end
diff --git a/app/workers/cluster_patch_app_worker.rb b/app/workers/cluster_patch_app_worker.rb
index bb16cf7a5e6..1ef9dc7f6fe 100644
--- a/app/workers/cluster_patch_app_worker.rb
+++ b/app/workers/cluster_patch_app_worker.rb
@@ -1,5 +1,8 @@
# frozen_string_literal: true
+# DEPRECATED
+#
+# To be removed by https://gitlab.com/gitlab-org/gitlab/-/issues/366573
class ClusterPatchAppWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
@@ -12,9 +15,5 @@ class ClusterPatchAppWorker # rubocop:disable Scalability/IdempotentWorker
worker_has_external_dependencies!
loggable_arguments 0
- def perform(app_name, app_id)
- find_application(app_name, app_id) do |app|
- Clusters::Applications::PatchService.new(app).execute
- end
- end
+ def perform(app_name, app_id); end
end
diff --git a/app/workers/cluster_update_app_worker.rb b/app/workers/cluster_update_app_worker.rb
index 97fdec02ba4..7d997c0a293 100644
--- a/app/workers/cluster_update_app_worker.rb
+++ b/app/workers/cluster_update_app_worker.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
# Deprecated, to be removed in %14.0 as part of https://gitlab.com/groups/gitlab-org/-/epics/4280
+# Also see https://gitlab.com/gitlab-org/gitlab/-/issues/366573
class ClusterUpdateAppWorker # rubocop:disable Scalability/IdempotentWorker
UpdateAlreadyInProgressError = Class.new(StandardError)
@@ -16,38 +17,5 @@ class ClusterUpdateAppWorker # rubocop:disable Scalability/IdempotentWorker
LEASE_TIMEOUT = 10.minutes.to_i
- def perform(app_name, app_id, project_id, scheduled_time)
- @app_id = app_id
-
- try_obtain_lease do
- execute(app_name, app_id, project_id, scheduled_time)
- end
- end
-
- private
-
- def execute(app_name, app_id, project_id, scheduled_time)
- project = Project.find_by_id(project_id)
- return unless project
-
- find_application(app_name, app_id) do |app|
- update_prometheus(app, scheduled_time, project)
- end
- end
-
- def update_prometheus(app, scheduled_time, project)
- return unless app.managed_prometheus?
- return if app.updated_since?(scheduled_time)
- return if app.update_in_progress?
-
- Clusters::Applications::PrometheusUpdateService.new(app, project).execute
- end
-
- def lease_key
- @lease_key ||= "#{self.class.name.underscore}-#{@app_id}"
- end
-
- def lease_timeout
- LEASE_TIMEOUT
- end
+ def perform(app_name, app_id, project_id, scheduled_time); end
end
diff --git a/app/workers/cluster_upgrade_app_worker.rb b/app/workers/cluster_upgrade_app_worker.rb
index bbe0cb7f0c2..40feee9374d 100644
--- a/app/workers/cluster_upgrade_app_worker.rb
+++ b/app/workers/cluster_upgrade_app_worker.rb
@@ -1,5 +1,8 @@
# frozen_string_literal: true
+# DEPRECATED
+#
+# To be removed by https://gitlab.com/gitlab-org/gitlab/-/issues/366573
class ClusterUpgradeAppWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
@@ -12,9 +15,5 @@ class ClusterUpgradeAppWorker # rubocop:disable Scalability/IdempotentWorker
worker_has_external_dependencies!
loggable_arguments 0
- def perform(app_name, app_id)
- find_application(app_name, app_id) do |app|
- Clusters::Applications::UpgradeService.new(app).execute
- end
- end
+ def perform(app_name, app_id); end
end
diff --git a/app/workers/cluster_wait_for_app_installation_worker.rb b/app/workers/cluster_wait_for_app_installation_worker.rb
index 846e4442233..ec291ddeb10 100644
--- a/app/workers/cluster_wait_for_app_installation_worker.rb
+++ b/app/workers/cluster_wait_for_app_installation_worker.rb
@@ -1,5 +1,8 @@
# frozen_string_literal: true
+# DEPRECATED
+#
+# To be removed by https://gitlab.com/gitlab-org/gitlab/-/issues/366573
class ClusterWaitForAppInstallationWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
@@ -16,9 +19,5 @@ class ClusterWaitForAppInstallationWorker # rubocop:disable Scalability/Idempote
worker_resource_boundary :cpu
loggable_arguments 0
- def perform(app_name, app_id)
- find_application(app_name, app_id) do |app|
- Clusters::Applications::CheckInstallationProgressService.new(app).execute
- end
- end
+ def perform(app_name, app_id); end
end
diff --git a/app/workers/cluster_wait_for_app_update_worker.rb b/app/workers/cluster_wait_for_app_update_worker.rb
index e96e03ae249..084e8b41a49 100644
--- a/app/workers/cluster_wait_for_app_update_worker.rb
+++ b/app/workers/cluster_wait_for_app_update_worker.rb
@@ -1,5 +1,8 @@
# frozen_string_literal: true
+# DEPRECATED
+#
+# To be removed by https://gitlab.com/gitlab-org/gitlab/-/issues/366573
class ClusterWaitForAppUpdateWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
@@ -14,9 +17,5 @@ class ClusterWaitForAppUpdateWorker # rubocop:disable Scalability/IdempotentWork
loggable_arguments 0
- def perform(app_name, app_id)
- find_application(app_name, app_id) do |app|
- ::Clusters::Applications::CheckUpgradeProgressService.new(app).execute
- end
- end
+ def perform(app_name, app_id); end
end
diff --git a/app/workers/cluster_wait_for_ingress_ip_address_worker.rb b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb
index 561e72562e9..8983942c0fb 100644
--- a/app/workers/cluster_wait_for_ingress_ip_address_worker.rb
+++ b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb
@@ -1,5 +1,8 @@
# frozen_string_literal: true
+# DEPRECATED
+#
+# To be removed by https://gitlab.com/gitlab-org/gitlab/-/issues/366573
class ClusterWaitForIngressIpAddressWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
@@ -12,9 +15,5 @@ class ClusterWaitForIngressIpAddressWorker # rubocop:disable Scalability/Idempot
worker_has_external_dependencies!
loggable_arguments 0
- def perform(app_name, app_id)
- find_application(app_name, app_id) do |app|
- Clusters::Applications::CheckIngressIpAddressService.new(app).execute
- end
- end
+ def perform(app_name, app_id); end
end
diff --git a/app/workers/clusters/applications/deactivate_integration_worker.rb b/app/workers/clusters/applications/deactivate_integration_worker.rb
index d1db99d21af..fca05e8ad2e 100644
--- a/app/workers/clusters/applications/deactivate_integration_worker.rb
+++ b/app/workers/clusters/applications/deactivate_integration_worker.rb
@@ -24,6 +24,8 @@ module Clusters
.include_integration(integration_association_name)
projects.find_each do |project|
+ # This use of public_send is safe because we constructed the
+ # integration_association_name ourselves above.
project.public_send(integration_association_name).update!(active: false) # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb
index 510ea8e7b17..974d99139a1 100644
--- a/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb
+++ b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb
@@ -1,5 +1,8 @@
# frozen_string_literal: true
+# DEPRECATED
+#
+# To be removed by https://gitlab.com/gitlab-org/gitlab/-/issues/366573
module Clusters
module Applications
class WaitForUninstallAppWorker # rubocop:disable Scalability/IdempotentWorker
@@ -18,11 +21,7 @@ module Clusters
worker_resource_boundary :cpu
loggable_arguments 0
- def perform(app_name, app_id)
- find_application(app_name, app_id) do |app|
- Clusters::Applications::CheckUninstallProgressService.new(app).execute
- end
- end
+ def perform(app_name, app_id); end
end
end
end
diff --git a/app/workers/concerns/limited_capacity/job_tracker.rb b/app/workers/concerns/limited_capacity/job_tracker.rb
index a1eb4e45027..4b5ce8a01f6 100644
--- a/app/workers/concerns/limited_capacity/job_tracker.rb
+++ b/app/workers/concerns/limited_capacity/job_tracker.rb
@@ -58,7 +58,7 @@ module LimitedCapacity
end
def remove_job_keys(redis, keys)
- redis.srem(counter_key, keys)
+ redis.srem?(counter_key, keys)
end
def with_redis(&block)
diff --git a/app/workers/container_registry/cleanup_worker.rb b/app/workers/container_registry/cleanup_worker.rb
new file mode 100644
index 00000000000..8350ae3431b
--- /dev/null
+++ b/app/workers/container_registry/cleanup_worker.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module ContainerRegistry
+ class CleanupWorker
+ include ApplicationWorker
+ # we don't have any project, user or group context here
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ data_consistency :always
+ idempotent!
+
+ feature_category :container_registry
+
+ STALE_DELETE_THRESHOLD = 30.minutes.freeze
+ BATCH_SIZE = 200
+
+ def perform
+ return unless Feature.enabled?(:container_registry_delete_repository_with_cron_worker)
+
+ log_counts
+
+ reset_stale_deletes
+
+ enqueue_delete_container_repository_jobs if ContainerRepository.delete_scheduled.exists?
+ end
+
+ private
+
+ def reset_stale_deletes
+ ContainerRepository.delete_ongoing.each_batch(of: BATCH_SIZE) do |batch|
+ batch.with_stale_delete_at(STALE_DELETE_THRESHOLD.ago).update_all(
+ status: :delete_scheduled,
+ delete_started_at: nil
+ )
+ end
+ end
+
+ def enqueue_delete_container_repository_jobs
+ ContainerRegistry::DeleteContainerRepositoryWorker.perform_with_capacity
+ end
+
+ def log_counts
+ ::Gitlab::Database::LoadBalancing::Session.current.use_replicas_for_read_queries do
+ log_extra_metadata_on_done(
+ :delete_scheduled_container_repositories_count,
+ ContainerRepository.delete_scheduled.count
+ )
+ log_extra_metadata_on_done(
+ :stale_delete_container_repositories_count,
+ stale_delete_container_repositories.count
+ )
+ end
+ end
+
+ def stale_delete_container_repositories
+ ContainerRepository.delete_ongoing.with_stale_delete_at(STALE_DELETE_THRESHOLD.ago)
+ end
+ end
+end
diff --git a/app/workers/container_registry/delete_container_repository_worker.rb b/app/workers/container_registry/delete_container_repository_worker.rb
new file mode 100644
index 00000000000..1f94b1b9e71
--- /dev/null
+++ b/app/workers/container_registry/delete_container_repository_worker.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+module ContainerRegistry
+ class DeleteContainerRepositoryWorker
+ include ApplicationWorker
+ include LimitedCapacity::Worker
+ include Gitlab::Utils::StrongMemoize
+ extend ::Gitlab::Utils::Override
+
+ data_consistency :always
+ queue_namespace :container_repository_delete
+ feature_category :container_registry
+ urgency :low
+ worker_resource_boundary :unknown
+ idempotent!
+
+ MAX_CAPACITY = 2
+ CLEANUP_TAGS_SERVICE_PARAMS = {
+ 'name_regex_delete' => '.*',
+ 'container_expiration_policy' => true # to avoid permissions checks
+ }.freeze
+
+ def perform_work
+ return unless next_container_repository
+
+ result = delete_tags
+ log_delete_tags_service_result(next_container_repository, result)
+
+ if result[:status] == :error || next_container_repository.tags_count != 0
+ return next_container_repository.set_delete_scheduled_status
+ end
+
+ next_container_repository.destroy!
+ rescue StandardError => exception
+ next_container_repository&.set_delete_scheduled_status
+
+ Gitlab::ErrorTracking.log_exception(exception, class: self.class.name)
+ end
+
+ def remaining_work_count
+ ::ContainerRepository.delete_scheduled.limit(max_running_jobs + 1).count
+ end
+
+ def max_running_jobs
+ MAX_CAPACITY
+ end
+
+ private
+
+ def delete_tags
+ service = Projects::ContainerRepository::CleanupTagsService.new(
+ container_repository: next_container_repository,
+ params: CLEANUP_TAGS_SERVICE_PARAMS
+ )
+ service.execute
+ end
+
+ def next_container_repository
+ strong_memoize(:next_container_repository) do
+ ContainerRepository.transaction do
+ # we don't care about the order
+ repository = ContainerRepository.next_pending_destruction(order_by: nil)
+
+ repository&.tap(&:set_delete_ongoing_status)
+ end
+ end
+ end
+
+ def log_delete_tags_service_result(container_repository, delete_tags_service_result)
+ logger.info(
+ structured_payload(
+ project_id: container_repository.project_id,
+ container_repository_id: container_repository.id,
+ container_repository_path: container_repository.path,
+ tags_size_before_delete: delete_tags_service_result[:original_size],
+ deleted_tags_size: delete_tags_service_result[:deleted_size]
+ )
+ )
+ 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
new file mode 100644
index 00000000000..098153c742f
--- /dev/null
+++ b/app/workers/database/batched_background_migration/execution_worker.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+module Database
+ module BatchedBackgroundMigration
+ class ExecutionWorker # rubocop:disable Scalability/IdempotentWorker
+ include ExclusiveLeaseGuard
+ include Gitlab::Utils::StrongMemoize
+
+ INTERVAL_VARIANCE = 5.seconds.freeze
+ LEASE_TIMEOUT_MULTIPLIER = 3
+
+ def perform(database_name, migration_id)
+ self.database_name = database_name
+
+ return unless enabled?
+ return if shares_db_config?
+
+ Gitlab::Database::SharedModel.using_connection(base_model.connection) do
+ self.migration = find_migration(migration_id)
+
+ break unless migration
+
+ try_obtain_lease do
+ run_migration_job if executable_migration?
+ end
+ end
+ end
+
+ private
+
+ attr_accessor :database_name, :migration
+
+ def enabled?
+ Feature.enabled?(:execute_batched_migrations_on_schedule, type: :ops)
+ end
+
+ def shares_db_config?
+ Gitlab::Database.db_config_share_with(base_model.connection_db_config).present?
+ end
+
+ def base_model
+ strong_memoize(:base_model) do
+ Gitlab::Database.database_base_models[database_name]
+ end
+ end
+
+ def find_migration(id)
+ Gitlab::Database::BackgroundMigration::BatchedMigration.find_executable(id, connection: base_model.connection)
+ end
+
+ def lease_key
+ @lease_key ||= [
+ self.class.name.underscore,
+ 'database_name',
+ database_name,
+ 'table_name',
+ migration.table_name
+ ].join(':')
+ end
+
+ def lease_timeout
+ migration.interval * LEASE_TIMEOUT_MULTIPLIER
+ end
+
+ def executable_migration?
+ migration.active? && migration.interval_elapsed?(variance: INTERVAL_VARIANCE)
+ end
+
+ def run_migration_job
+ Gitlab::Database::BackgroundMigration::BatchedMigrationRunner.new(connection: base_model.connection)
+ .run_migration_job(migration)
+ end
+ end
+ end
+end
diff --git a/app/workers/database/batched_background_migration/single_database_worker.rb b/app/workers/database/batched_background_migration/single_database_worker.rb
index cfbd44ba397..0c7c51d5c0a 100644
--- a/app/workers/database/batched_background_migration/single_database_worker.rb
+++ b/app/workers/database/batched_background_migration/single_database_worker.rb
@@ -58,14 +58,7 @@ module Database
break unless self.class.enabled? && active_migration
with_exclusive_lease(active_migration.interval) do
- # Now that we have the exclusive lease, reload migration in case another process has changed it.
- # This is a temporary solution until we have better concurrency handling around job execution
- #
- # We also have to disable this cop, because ApplicationRecord aliases reset to reload, but our database
- # models don't inherit from ApplicationRecord
- active_migration.reload # rubocop:disable Cop/ActiveRecordAssociationReload
-
- run_active_migration if active_migration.active? && active_migration.interval_elapsed?(variance: INTERVAL_VARIANCE)
+ run_active_migration
end
end
end
@@ -77,7 +70,7 @@ module Database
end
def run_active_migration
- Gitlab::Database::BackgroundMigration::BatchedMigrationRunner.new(connection: base_model.connection).run_migration_job(active_migration)
+ Database::BatchedBackgroundMigration::ExecutionWorker.new.perform(self.class.tracking_database, active_migration.id)
end
def base_model
diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb
index fdf4ec6f396..a9f645bd634 100644
--- a/app/workers/gitlab/github_import/advance_stage_worker.rb
+++ b/app/workers/gitlab/github_import/advance_stage_worker.rb
@@ -21,6 +21,7 @@ module Gitlab
# The known importer stages and their corresponding Sidekiq workers.
STAGES = {
pull_requests_merged_by: Stage::ImportPullRequestsMergedByWorker,
+ pull_request_review_requests: Stage::ImportPullRequestsReviewRequestsWorker,
pull_request_reviews: Stage::ImportPullRequestsReviewsWorker,
issues_and_diff_notes: Stage::ImportIssuesAndDiffNotesWorker,
issue_events: Stage::ImportIssueEventsWorker,
diff --git a/app/workers/gitlab/github_import/pull_requests/import_review_request_worker.rb b/app/workers/gitlab/github_import/pull_requests/import_review_request_worker.rb
new file mode 100644
index 00000000000..e475a39810d
--- /dev/null
+++ b/app/workers/gitlab/github_import/pull_requests/import_review_request_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ module PullRequests
+ class ImportReviewRequestWorker # rubocop:disable Scalability/IdempotentWorker
+ include ObjectImporter
+
+ worker_resource_boundary :cpu
+
+ def representation_class
+ Gitlab::GithubImport::Representation::PullRequests::ReviewRequests
+ end
+
+ def importer_class
+ Importer::PullRequests::ReviewRequestImporter
+ end
+
+ def object_type
+ :pull_request_review_request
+ end
+ end
+ end
+ end
+end
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 8c2d652a689..9b123b5776a 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
@@ -24,7 +24,7 @@ module Gitlab
AdvanceStageWorker.perform_async(
project.id,
{ waiter.key => waiter.jobs_remaining },
- :pull_request_reviews
+ :pull_request_review_requests
)
end
end
diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker.rb
new file mode 100644
index 00000000000..bcbf5dd471a
--- /dev/null
+++ b/app/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ module Stage
+ class ImportPullRequestsReviewRequestsWorker # 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)
+ waiter = Importer::PullRequests::ReviewRequestsImporter
+ .new(project, client)
+ .execute
+
+ project.import_state.refresh_jid_expiration
+
+ AdvanceStageWorker.perform_async(
+ project.id,
+ { waiter.key => waiter.jobs_remaining },
+ :pull_request_reviews
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab_performance_bar_stats_worker.rb b/app/workers/gitlab_performance_bar_stats_worker.rb
index 6d637ad1586..0b31c159726 100644
--- a/app/workers/gitlab_performance_bar_stats_worker.rb
+++ b/app/workers/gitlab_performance_bar_stats_worker.rb
@@ -18,7 +18,7 @@ class GitlabPerformanceBarStatsWorker
idempotent!
def perform(lease_uuid)
- Gitlab::Redis::Cache.with do |redis|
+ with_redis do |redis|
request_ids = fetch_request_ids(redis, lease_uuid)
stats = Gitlab::PerformanceBar::Stats.new(redis)
@@ -30,6 +30,10 @@ class GitlabPerformanceBarStatsWorker
private
+ def with_redis(&block)
+ Gitlab::Redis::Cache.with(&block) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
def fetch_request_ids(redis, lease_uuid)
ids = redis.smembers(STATS_KEY)
redis.del(STATS_KEY)
diff --git a/app/workers/gitlab_shell_worker.rb b/app/workers/gitlab_shell_worker.rb
index 1bcaf5a42be..2f396dcdb86 100644
--- a/app/workers/gitlab_shell_worker.rb
+++ b/app/workers/gitlab_shell_worker.rb
@@ -14,18 +14,12 @@ class GitlabShellWorker # rubocop:disable Scalability/IdempotentWorker
loggable_arguments 0
def perform(action, *arg)
- # Gitlab::Shell is being removed but we need to continue to process jobs
- # enqueued in the previous release, so handle them here.
- #
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/25095 for more details
- if AuthorizedKeysWorker::PERMITTED_ACTIONS.include?(action.to_s)
- AuthorizedKeysWorker.new.perform(action, *arg)
-
- return
+ if ::Feature.enabled?(:verify_gitlab_shell_worker_method_names) && Gitlab::Shell::PERMITTED_ACTIONS.exclude?(action)
+ raise(ArgumentError, "#{action} not allowed for #{self.class.name}")
end
Gitlab::GitalyClient::NamespaceService.allow do
- gitlab_shell.__send__(action, *arg) # rubocop:disable GitlabSecurity/PublicSend
+ gitlab_shell.public_send(action, *arg) # rubocop:disable GitlabSecurity/PublicSend
end
end
end
diff --git a/app/workers/incident_management/add_severity_system_note_worker.rb b/app/workers/incident_management/add_severity_system_note_worker.rb
index 3a4667bea0a..b2d2d6748ee 100644
--- a/app/workers/incident_management/add_severity_system_note_worker.rb
+++ b/app/workers/incident_management/add_severity_system_note_worker.rb
@@ -21,7 +21,10 @@ module IncidentManagement
user = User.find_by_id(user_id)
return unless user
- SystemNoteService.change_incident_severity(incident, user)
+ incident.transaction do
+ SystemNoteService.change_incident_severity(incident, user)
+ TimelineEvents::CreateService.change_severity(incident, user)
+ end
end
end
end
diff --git a/app/workers/loose_foreign_keys/cleanup_worker.rb b/app/workers/loose_foreign_keys/cleanup_worker.rb
index 0a3a834578a..9a0909598bb 100644
--- a/app/workers/loose_foreign_keys/cleanup_worker.rb
+++ b/app/workers/loose_foreign_keys/cleanup_worker.rb
@@ -12,7 +12,11 @@ module LooseForeignKeys
idempotent!
def perform
- in_lock(self.class.name.underscore, ttl: ModificationTracker::MAX_RUNTIME, retries: 0) do
+ # Add small buffer on MAX_RUNTIME to account for single long running
+ # query or extra worker time after the cleanup.
+ lock_ttl = ModificationTracker::MAX_RUNTIME + 20.seconds
+
+ in_lock(self.class.name.underscore, ttl: lock_ttl, retries: 0) do
stats = {}
connection_name, base_model = current_connection_name_and_base_model
diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb
index 25c9ac5547b..12e8de4491e 100644
--- a/app/workers/mail_scheduler/notification_service_worker.rb
+++ b/app/workers/mail_scheduler/notification_service_worker.rb
@@ -18,6 +18,12 @@ module MailScheduler
def perform(meth, *args)
check_arguments!(args)
+ if ::Feature.enabled?(:verify_mail_scheduler_notification_service_worker_method_names) &&
+ NotificationService.permitted_actions.exclude?(meth.to_sym)
+
+ raise(ArgumentError, "#{meth} not allowed for #{self.class.name}")
+ end
+
deserialized_args = ActiveJob::Arguments.deserialize(args)
notification_service.public_send(meth, *deserialized_args) # rubocop:disable GitlabSecurity/PublicSend
rescue ActiveJob::DeserializationError
diff --git a/app/workers/merge_requests/delete_branch_worker.rb b/app/workers/merge_requests/delete_branch_worker.rb
new file mode 100644
index 00000000000..6816f9a4b77
--- /dev/null
+++ b/app/workers/merge_requests/delete_branch_worker.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class DeleteBranchWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ feature_category :source_code_management
+ urgency :high
+ idempotent!
+
+ def perform(merge_request_id, user_id, branch_name, retarget_branch)
+ merge_request = MergeRequest.find_by_id(merge_request_id)
+ user = User.find_by_id(user_id)
+
+ return unless merge_request.present? && user.present?
+
+ ::Branches::DeleteService.new(merge_request.source_project, user).execute(branch_name)
+
+ return unless retarget_branch
+
+ ::MergeRequests::RetargetChainService.new(project: merge_request.source_project, current_user: user)
+ .execute(merge_request)
+ end
+ end
+end
diff --git a/app/workers/merge_requests/delete_source_branch_worker.rb b/app/workers/merge_requests/delete_source_branch_worker.rb
index 66392c670b5..96dde413d5b 100644
--- a/app/workers/merge_requests/delete_source_branch_worker.rb
+++ b/app/workers/merge_requests/delete_source_branch_worker.rb
@@ -18,15 +18,14 @@ class MergeRequests::DeleteSourceBranchWorker
# Source branch changed while it's being removed
return if merge_request.source_branch_sha != source_branch_sha
- delete_service_result = ::Branches::DeleteService.new(merge_request.source_project, user)
- .execute(merge_request.source_branch)
+ if Feature.enabled?(:add_delete_branch_worker, merge_request.source_project)
+ ::MergeRequests::DeleteBranchWorker.perform_async(merge_request_id, user_id, merge_request.source_branch, true)
+ else
+ ::Branches::DeleteService.new(merge_request.source_project, user).execute(merge_request.source_branch)
- if Feature.enabled?(:track_delete_source_errors, merge_request.source_project)
- delete_service_result.track_exception if delete_service_result&.error?
+ ::MergeRequests::RetargetChainService.new(project: merge_request.source_project, current_user: user)
+ .execute(merge_request)
end
-
- ::MergeRequests::RetargetChainService.new(project: merge_request.source_project, current_user: user)
- .execute(merge_request)
rescue ActiveRecord::RecordNotFound
end
end
diff --git a/app/workers/namespaces/root_statistics_worker.rb b/app/workers/namespaces/root_statistics_worker.rb
index e1271dae335..e3aa8a1f779 100644
--- a/app/workers/namespaces/root_statistics_worker.rb
+++ b/app/workers/namespaces/root_statistics_worker.rb
@@ -4,13 +4,14 @@ module Namespaces
class RootStatisticsWorker
include ApplicationWorker
- data_consistency :always
+ data_consistency :sticky, feature_flag: :root_statistics_worker_read_replica
sidekiq_options retry: 3
queue_namespace :update_namespace_statistics
feature_category :source_code_management
idempotent!
+ deduplicate :until_executed, if_deduplicated: :reschedule_once
def perform(namespace_id)
namespace = Namespace.find(namespace_id)
diff --git a/app/workers/onboarding/issue_created_worker.rb b/app/workers/onboarding/issue_created_worker.rb
index ff39fefad81..73e96850786 100644
--- a/app/workers/onboarding/issue_created_worker.rb
+++ b/app/workers/onboarding/issue_created_worker.rb
@@ -22,6 +22,3 @@ module Onboarding
end
end
end
-
-# remove in %15.6 as per https://gitlab.com/gitlab-org/gitlab/-/issues/372432
-Namespaces::OnboardingIssueCreatedWorker = Onboarding::IssueCreatedWorker
diff --git a/app/workers/onboarding/pipeline_created_worker.rb b/app/workers/onboarding/pipeline_created_worker.rb
index 6bd5863b0e0..c6e84882d6f 100644
--- a/app/workers/onboarding/pipeline_created_worker.rb
+++ b/app/workers/onboarding/pipeline_created_worker.rb
@@ -22,6 +22,3 @@ module Onboarding
end
end
end
-
-# remove in %15.6 as per https://gitlab.com/gitlab-org/gitlab/-/issues/372432
-Namespaces::OnboardingPipelineCreatedWorker = Onboarding::PipelineCreatedWorker
diff --git a/app/workers/onboarding/progress_worker.rb b/app/workers/onboarding/progress_worker.rb
index 525934c4a7c..34503bfa451 100644
--- a/app/workers/onboarding/progress_worker.rb
+++ b/app/workers/onboarding/progress_worker.rb
@@ -23,6 +23,3 @@ module Onboarding
end
end
end
-
-# remove in %15.6 as per https://gitlab.com/gitlab-org/gitlab/-/issues/372432
-Namespaces::OnboardingProgressWorker = Onboarding::ProgressWorker
diff --git a/app/workers/onboarding/user_added_worker.rb b/app/workers/onboarding/user_added_worker.rb
index 38e9cd063ea..b096bf752dc 100644
--- a/app/workers/onboarding/user_added_worker.rb
+++ b/app/workers/onboarding/user_added_worker.rb
@@ -19,6 +19,3 @@ module Onboarding
end
end
end
-
-# remove in %15.6 as per https://gitlab.com/gitlab-org/gitlab/-/issues/372432
-Namespaces::OnboardingUserAddedWorker = Onboarding::UserAddedWorker
diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb
index 3aff4b42629..adb6d38fd12 100644
--- a/app/workers/pages_worker.rb
+++ b/app/workers/pages_worker.rb
@@ -11,7 +11,7 @@ class PagesWorker # rubocop:disable Scalability/IdempotentWorker
worker_resource_boundary :cpu
def perform(action, *arg)
- send(action, *arg) # rubocop:disable GitlabSecurity/PublicSend
+ deploy(*arg) if action == 'deploy'
end
def deploy(build_id)
diff --git a/app/workers/projects/inactive_projects_deletion_cron_worker.rb b/app/workers/projects/inactive_projects_deletion_cron_worker.rb
index ba6d44ec4a5..af62efeb089 100644
--- a/app/workers/projects/inactive_projects_deletion_cron_worker.rb
+++ b/app/workers/projects/inactive_projects_deletion_cron_worker.rb
@@ -90,22 +90,26 @@ module Projects
end
def save_last_processed_project_id(project_id)
- Gitlab::Redis::Cache.with do |redis|
+ with_redis do |redis|
redis.set(LAST_PROCESSED_INACTIVE_PROJECT_REDIS_KEY, project_id)
end
end
def last_processed_project_id
- Gitlab::Redis::Cache.with do |redis|
+ with_redis do |redis|
redis.get(LAST_PROCESSED_INACTIVE_PROJECT_REDIS_KEY).to_i
end
end
def reset_last_processed_project_id
- Gitlab::Redis::Cache.with do |redis|
+ with_redis do |redis|
redis.del(LAST_PROCESSED_INACTIVE_PROJECT_REDIS_KEY)
end
end
+
+ def with_redis(&block)
+ Gitlab::Redis::Cache.with(&block) # rubocop:disable CodeReuse/ActiveRecord
+ end
end
end
diff --git a/app/workers/projects/post_creation_worker.rb b/app/workers/projects/post_creation_worker.rb
index 3a39bd17ce3..886919ecace 100644
--- a/app/workers/projects/post_creation_worker.rb
+++ b/app/workers/projects/post_creation_worker.rb
@@ -17,6 +17,7 @@ module Projects
return unless project
create_prometheus_integration(project)
+ create_incident_management_timeline_event_tags(project)
end
private
@@ -34,5 +35,19 @@ module Projects
rescue ActiveRecord::RecordInvalid => e
Gitlab::ErrorTracking.track_exception(e, extra: { project_id: project.id })
end
+
+ def create_incident_management_timeline_event_tags(project)
+ tags = project.incident_management_timeline_event_tags.pluck_names
+ start_time_name = ::IncidentManagement::TimelineEventTag::START_TIME_TAG_NAME
+ end_time_name = ::IncidentManagement::TimelineEventTag::END_TIME_TAG_NAME
+
+ project.incident_management_timeline_event_tags.new(name: start_time_name) unless tags.include?(start_time_name)
+
+ project.incident_management_timeline_event_tags.new(name: end_time_name) unless tags.include?(end_time_name)
+
+ project.save!
+ rescue StandardError => e
+ Gitlab::ErrorTracking.track_exception(e, extra: { project_id: project.id })
+ end
end
end
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index 413bb135943..5e89b9f3362 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -33,11 +33,9 @@ class RepositoryImportWorker # rubocop:disable Scalability/IdempotentWorker
if result[:status] == :error
fail_import(result[:message])
-
- raise result[:message]
+ else
+ project.after_import
end
-
- project.after_import
end
private
diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb
index 35e3e633c70..8974ddce47b 100644
--- a/app/workers/run_pipeline_schedule_worker.rb
+++ b/app/workers/run_pipeline_schedule_worker.rb
@@ -21,13 +21,14 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker
end
def run_pipeline_schedule(schedule, user)
- Ci::CreatePipelineService.new(schedule.project,
- user,
- ref: schedule.ref)
- .execute!(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule)
- rescue Ci::CreatePipelineService::CreateError => e
+ response = Ci::CreatePipelineService
+ .new(schedule.project, user, ref: schedule.ref)
+ .execute(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule)
+
+ return response if response.payload.persisted?
+
# This is a user operation error such as corrupted .gitlab-ci.yml. Log the error for debugging purpose.
- log_extra_metadata_on_done(:pipeline_creation_error, e)
+ log_extra_metadata_on_done(:pipeline_creation_error, response.message)
rescue StandardError => e
error(schedule, e)
end
diff --git a/app/workers/update_highest_role_worker.rb b/app/workers/update_highest_role_worker.rb
index 064b8203d4d..a05c9c7a1e7 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 :utilization
+ feature_category :subscription_usage_reports
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 b14b7e67450..c3799480b12 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 :utilization
+ feature_category :subscription_cost_management
def perform
return if Gitlab.com?
diff --git a/app/workers/users/migrate_records_to_ghost_user_in_batches_worker.rb b/app/workers/users/migrate_records_to_ghost_user_in_batches_worker.rb
index ddddfc106ae..d9a80b6e899 100644
--- a/app/workers/users/migrate_records_to_ghost_user_in_batches_worker.rb
+++ b/app/workers/users/migrate_records_to_ghost_user_in_batches_worker.rb
@@ -12,8 +12,6 @@ module Users
idempotent!
def perform
- return unless Feature.enabled?(:user_destroy_with_limited_execution_time_worker)
-
in_lock(self.class.name.underscore, ttl: Gitlab::Utils::ExecutionTracker::MAX_RUNTIME, retries: 0) do
Users::MigrateRecordsToGhostUserInBatchesService.new.execute
end