summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue112
-rw-r--r--app/assets/javascripts/admin/background_migrations/components/database_listbox.vue2
-rw-r--r--app/assets/javascripts/admin/users/components/actions/ban.vue4
-rw-r--r--app/assets/javascripts/alert_handler.js3
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue1
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue4
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/metric_tile.vue51
-rw-r--r--app/assets/javascripts/api/user_api.js2
-rw-r--r--app/assets/javascripts/artifacts/components/artifact_row.vue2
-rw-r--r--app/assets/javascripts/artifacts/components/feedback_banner.vue41
-rw-r--r--app/assets/javascripts/artifacts/components/job_artifacts_table.vue6
-rw-r--r--app/assets/javascripts/artifacts/constants.js7
-rw-r--r--app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql1
-rw-r--r--app/assets/javascripts/artifacts/index.js5
-rw-r--r--app/assets/javascripts/autosave.js59
-rw-r--r--app/assets/javascripts/batch_comments/components/submit_dropdown.vue3
-rw-r--r--app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js1
-rw-r--r--app/assets/javascripts/behaviors/markdown/init_gfm.js13
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js4
-rw-r--r--app/assets/javascripts/behaviors/preview_markdown.js4
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcut.vue3
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js2
-rw-r--r--app/assets/javascripts/blob/components/blob_header.vue2
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js90
-rw-r--r--app/assets/javascripts/blob/notebook/index.js6
-rw-r--r--app/assets/javascripts/blob/openapi/index.js4
-rw-r--r--app/assets/javascripts/blob/template_selectors/type_selector.js24
-rw-r--r--app/assets/javascripts/blob/viewer/index.js4
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js4
-rw-r--r--app/assets/javascripts/boards/components/board_app.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue15
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue9
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue8
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue14
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue18
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue8
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue9
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue7
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue7
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue6
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue7
-rw-r--r--app/assets/javascripts/boards/components/issue_board_filtered_search.vue13
-rw-r--r--app/assets/javascripts/boards/index.js12
-rw-r--r--app/assets/javascripts/boards/issue_board_filters.js7
-rw-r--r--app/assets/javascripts/boards/stores/getters.js4
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/ci_variable_list.js (renamed from app/assets/javascripts/ci_variable_list/ci_variable_list.js)0
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_admin_variables.vue (renamed from app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue)0
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue (renamed from app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue)57
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue (renamed from app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue)0
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue (renamed from app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue)0
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_autocomplete_tokens.js (renamed from app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js)0
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue (renamed from app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue)1
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue (renamed from app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue)0
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue (renamed from app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue)0
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue (renamed from app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue)0
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/constants.js (renamed from app/assets/javascripts/ci_variable_list/constants.js)0
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql (renamed from app/assets/javascripts/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql)0
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql (renamed from app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql)2
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql (renamed from app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql)2
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql (renamed from app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql)2
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql (renamed from app/assets/javascripts/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql)0
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql (renamed from app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql)2
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql (renamed from app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql)2
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql (renamed from app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql)2
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql (renamed from app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql)2
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql (renamed from app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql)2
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql (renamed from app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql)2
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql (renamed from app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql)2
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql (renamed from app/assets/javascripts/ci_variable_list/graphql/queries/project_environments.query.graphql)0
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql (renamed from app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql)2
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql (renamed from app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql)2
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/settings.js (renamed from app/assets/javascripts/ci_variable_list/graphql/settings.js)4
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/index.js (renamed from app/assets/javascripts/ci_variable_list/index.js)0
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/native_form_variable_list.js (renamed from app/assets/javascripts/ci_variable_list/native_form_variable_list.js)0
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/utils.js (renamed from app/assets/javascripts/ci_variable_list/utils.js)0
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue8
-rw-r--r--app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue (renamed from app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue)0
-rw-r--r--app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue86
-rw-r--r--app/assets/javascripts/ci/pipeline_new/constants.js (renamed from app/assets/javascripts/pipeline_new/constants.js)0
-rw-r--r--app/assets/javascripts/ci/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql (renamed from app/assets/javascripts/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql)0
-rw-r--r--app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql (renamed from app/assets/javascripts/pipeline_new/graphql/queries/ci_config_variables.graphql)0
-rw-r--r--app/assets/javascripts/ci/pipeline_new/graphql/resolvers.js (renamed from app/assets/javascripts/pipeline_new/graphql/resolvers.js)0
-rw-r--r--app/assets/javascripts/ci/pipeline_new/index.js (renamed from app/assets/javascripts/pipeline_new/index.js)0
-rw-r--r--app/assets/javascripts/ci/pipeline_new/utils/filter_variables.js (renamed from app/assets/javascripts/pipeline_new/utils/filter_variables.js)0
-rw-r--r--app/assets/javascripts/ci/pipeline_new/utils/format_refs.js55
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue147
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue63
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue9
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue10
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue1
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/play_pipeline_schedule.mutation.graphql6
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js3
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_jobs_table.vue6
-rw-r--r--app/assets/javascripts/ci/runner/components/stat/runner_stats.vue4
-rw-r--r--app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql2
-rw-r--r--app/assets/javascripts/ci/runner/project_runners/index.js23
-rw-r--r--app/assets/javascripts/ci/runner/project_runners/project_runners_app.vue19
-rw-r--r--app/assets/javascripts/ci_settings_pipeline_triggers/index.js5
-rw-r--r--app/assets/javascripts/constants.js7
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue14
-rw-r--r--app/assets/javascripts/content_editor/components/formatting_toolbar.vue9
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue50
-rw-r--r--app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue4
-rw-r--r--app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue2
-rw-r--r--app/assets/javascripts/deprecated_notes.js28
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue3
-rw-r--r--app/assets/javascripts/diff.js23
-rw-r--r--app/assets/javascripts/diffs/components/app.vue9
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue1
-rw-r--r--app/assets/javascripts/diffs/components/merge_conflict_warning.vue4
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue7
-rw-r--r--app/assets/javascripts/diffs/store/actions.js6
-rw-r--r--app/assets/javascripts/editor/constants.js12
-rw-r--r--app/assets/javascripts/editor/schema/ci.json27
-rw-r--r--app/assets/javascripts/environments/environment_details/components/deployment_job.vue24
-rw-r--r--app/assets/javascripts/environments/environment_details/components/deployment_status_link.vue26
-rw-r--r--app/assets/javascripts/environments/environment_details/components/deployment_triggerer.vue25
-rw-r--r--app/assets/javascripts/environments/environment_details/constants.js17
-rw-r--r--app/assets/javascripts/environments/environment_details/deployments_table.vue55
-rw-r--r--app/assets/javascripts/environments/environment_details/empty_state.vue34
-rw-r--r--app/assets/javascripts/environments/environment_details/index.vue169
-rw-r--r--app/assets/javascripts/environments/environment_details/pagination.vue74
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql23
-rw-r--r--app/assets/javascripts/environments/mount_show.js37
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue20
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue41
-rw-r--r--app/assets/javascripts/flash.js96
-rw-r--r--app/assets/javascripts/frequent_items/components/app.vue34
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list.vue12
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue76
-rw-r--r--app/assets/javascripts/frequent_items/constants.js2
-rw-r--r--app/assets/javascripts/frequent_items/store/actions.js29
-rw-r--r--app/assets/javascripts/frequent_items/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/frequent_items/store/mutations.js16
-rw-r--r--app/assets/javascripts/frequent_items/store/state.js2
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js23
-rw-r--r--app/assets/javascripts/graphql_shared/issuable_client.js41
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json3
-rw-r--r--app/assets/javascripts/groups_select.js122
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue4
-rw-r--r--app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js9
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js6
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/actions.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/messages.js4
-rw-r--r--app/assets/javascripts/import_entities/components/import_status.vue33
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue35
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue60
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js1
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql1
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue4
-rw-r--r--app/assets/javascripts/import_entities/import_projects/index.js8
-rw-r--r--app/assets/javascripts/init_diff_stats_dropdown.js2
-rw-r--r--app/assets/javascripts/integrations/constants.js4
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue78
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_forms/section.vue90
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/trigger.vue3
-rw-r--r--app/assets/javascripts/integrations/edit/components/trigger_field.vue29
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue7
-rw-r--r--app/assets/javascripts/invite_members/components/user_limit_notification.vue32
-rw-r--r--app/assets/javascripts/invite_members/constants.js12
-rw-r--r--app/assets/javascripts/issuable/components/issuable_header_warnings.vue11
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue1
-rw-r--r--app/assets/javascripts/issuable/issuable_form.js21
-rw-r--r--app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue116
-rw-r--r--app/assets/javascripts/issues/dashboard/index.js12
-rw-r--r--app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql12
-rw-r--r--app/assets/javascripts/issues/dashboard/utils.js23
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue1
-rw-r--r--app/assets/javascripts/issues/list/constants.js13
-rw-r--r--app/assets/javascripts/issues/list/utils.js12
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue22
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/constants.js13
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue1
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue70
-rw-r--r--app/assets/javascripts/issues/show/index.js6
-rw-r--r--app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue87
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/app.vue6
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue73
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue16
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue4
-rw-r--r--app/assets/javascripts/jobs/components/job/manual_variables_form.vue16
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table.vue6
-rw-r--r--app/assets/javascripts/jobs/store/actions.js4
-rw-r--r--app/assets/javascripts/language_switcher/components/app.vue26
-rw-r--r--app/assets/javascripts/layout_nav.js77
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js12
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js2
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_format_utility.js10
-rw-r--r--app/assets/javascripts/lib/utils/http_status.js23
-rw-r--r--app/assets/javascripts/lib/utils/poll_until_complete.js4
-rw-r--r--app/assets/javascripts/locale/ensure_single_line.cjs (renamed from app/assets/javascripts/locale/ensure_single_line.js)0
-rw-r--r--app/assets/javascripts/locale/index.js2
-rw-r--r--app/assets/javascripts/main.js10
-rw-r--r--app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue2
-rw-r--r--app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue1
-rw-r--r--app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue2
-rw-r--r--app/assets/javascripts/members/components/action_buttons/leave_button.vue40
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue1
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_member_button.vue31
-rw-r--r--app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue95
-rw-r--r--app/assets/javascripts/members/components/action_dropdowns/constants.js22
-rw-r--r--app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue36
-rw-r--r--app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue86
-rw-r--r--app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue134
-rw-r--r--app/assets/javascripts/members/components/modals/leave_modal.vue63
-rw-r--r--app/assets/javascripts/members/components/modals/remove_member_modal.vue48
-rw-r--r--app/assets/javascripts/members/components/table/created_at.vue8
-rw-r--r--app/assets/javascripts/members/components/table/member_action_buttons.vue11
-rw-r--r--app/assets/javascripts/members/components/table/member_activity.vue38
-rw-r--r--app/assets/javascripts/members/components/table/member_source.vue41
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue42
-rw-r--r--app/assets/javascripts/members/components/table/members_table_cell.vue7
-rw-r--r--app/assets/javascripts/members/components/table/role_dropdown.vue40
-rw-r--r--app/assets/javascripts/members/constants.js17
-rw-r--r--app/assets/javascripts/members/guest_overage_confirm_action.js3
-rw-r--r--app/assets/javascripts/members/index.js6
-rw-r--r--app/assets/javascripts/members/utils.js6
-rw-r--r--app/assets/javascripts/merge_request_tabs.js42
-rw-r--r--app/assets/javascripts/merge_requests/components/sticky_header.vue8
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue50
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue89
-rw-r--r--app/assets/javascripts/monitoring/requests/index.js8
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js6
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js8
-rw-r--r--app/assets/javascripts/nav/components/new_nav_toggle.vue29
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue11
-rw-r--r--app/assets/javascripts/notes/mixins/autosave.js3
-rw-r--r--app/assets/javascripts/notes/stores/actions.js2
-rw-r--r--app/assets/javascripts/notifications/components/notification_email_listbox_input.vue46
-rw-r--r--app/assets/javascripts/notifications/index.js27
-rw-r--r--app/assets/javascripts/observability/components/observability_app.vue26
-rw-r--r--app/assets/javascripts/observability/components/skeleton/index.vue128
-rw-r--r--app/assets/javascripts/observability/constants.js30
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue51
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_history.vue8
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue12
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue29
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue26
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js8
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue56
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue8
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js60
-rw-r--r--app/assets/javascripts/pages/admin/broadcast_messages/edit/index.js7
-rw-r--r--app/assets/javascripts/pages/admin/broadcast_messages/index/index.js9
-rw-r--r--app/assets/javascripts/pages/groups/boards/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/observability/datasources/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/show/index.js2
-rw-r--r--app/assets/javascripts/pages/import/bitbucket/status/index.js21
-rw-r--r--app/assets/javascripts/pages/import/bitbucket_server/status/index.js30
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue65
-rw-r--r--app/assets/javascripts/pages/import/fogbugz/status/index.js2
-rw-r--r--app/assets/javascripts/pages/import/gitea/status/index.js2
-rw-r--r--app/assets/javascripts/pages/import/github/status/index.js2
-rw-r--r--app/assets/javascripts/pages/import/gitlab/status/index.js2
-rw-r--r--app/assets/javascripts/pages/import/manifest/status/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/compare/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue84
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue78
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue9
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/ml/experiments/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue4
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/settings/merge_requests/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/usage_quotas/index.js9
-rw-r--r--app/assets/javascripts/pages/users/index.js2
-rw-r--r--app/assets/javascripts/pipeline_new/components/refs_dropdown.vue114
-rw-r--r--app/assets/javascripts/pipeline_new/utils/format_refs.js18
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/wrapper.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue28
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue9
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue44
-rw-r--r--app/assets/javascripts/pipelines/constants.js4
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines_mixin.js4
-rw-r--r--app/assets/javascripts/profile/preferences/profile_preferences_bundle.js3
-rw-r--r--app/assets/javascripts/profile/profile.js4
-rw-r--r--app/assets/javascripts/projects/commit/components/branches_dropdown.vue67
-rw-r--r--app/assets/javascripts/projects/commit/components/form_modal.vue13
-rw-r--r--app/assets/javascripts/projects/commit/components/projects_dropdown.vue57
-rw-r--r--app/assets/javascripts/projects/commits/index.js19
-rw-r--r--app/assets/javascripts/projects/merge_requests/components/report_abuse_dropdown_item.vue41
-rw-r--r--app/assets/javascripts/projects/merge_requests/index.js18
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue8
-rw-r--r--app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js4
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue12
-rw-r--r--app/assets/javascripts/read_more.js4
-rw-r--r--app/assets/javascripts/ref/components/ref_results_section.vue8
-rw-r--r--app/assets/javascripts/ref/components/ref_selector.vue15
-rw-r--r--app/assets/javascripts/ref/constants.js2
-rw-r--r--app/assets/javascripts/ref/stores/mutations.js4
-rw-r--r--app/assets/javascripts/ref_select_dropdown.js51
-rw-r--r--app/assets/javascripts/repository/commits_service.js2
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/index.js2
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/notebook_viewer.vue31
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/openapi_viewer.vue24
-rw-r--r--app/assets/javascripts/repository/components/fork_info.vue146
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue17
-rw-r--r--app/assets/javascripts/repository/constants.js6
-rw-r--r--app/assets/javascripts/repository/index.js24
-rw-r--r--app/assets/javascripts/repository/queries/fork_details.query.graphql9
-rw-r--r--app/assets/javascripts/repository/utils/ref_switcher_utils.js8
-rw-r--r--app/assets/javascripts/search/index.js11
-rw-r--r--app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue2
-rw-r--r--app/assets/javascripts/search/sidebar/components/results_filters.vue2
-rw-r--r--app/assets/javascripts/search/sidebar/components/scope_navigation.vue17
-rw-r--r--app/assets/javascripts/search/sidebar/components/status_filter.vue2
-rw-r--r--app/assets/javascripts/search/sidebar/constants/index.js13
-rw-r--r--app/assets/javascripts/search/sidebar/index.js6
-rw-r--r--app/assets/javascripts/search/store/constants.js2
-rw-r--r--app/assets/javascripts/search/store/utils.js24
-rw-r--r--app/assets/javascripts/search/topbar/constants.js2
-rw-r--r--app/assets/javascripts/self_monitor/store/actions.js6
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_form.vue39
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue27
-rw-r--r--app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue54
-rw-r--r--app/assets/javascripts/set_status_modal/utils.js10
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue5
-rw-r--r--app/assets/javascripts/super_sidebar/components/bottom_bar.vue24
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher.vue83
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue45
-rw-r--r--app/assets/javascripts/super_sidebar/components/counter.vue48
-rw-r--r--app/assets/javascripts/super_sidebar/components/nav_item.vue37
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar.vue50
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_bar.vue77
-rw-r--r--app/assets/javascripts/super_sidebar/mock_data.js59
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_bundle.js26
-rw-r--r--app/assets/javascripts/syntax_highlight.js2
-rw-r--r--app/assets/javascripts/terraform/components/states_table.vue6
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue134
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue142
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue35
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue170
-rw-r--r--app/assets/javascripts/usage_quotas/storage/constants.js100
-rw-r--r--app/assets/javascripts/usage_quotas/storage/init_project_storage.js34
-rw-r--r--app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql17
-rw-r--r--app/assets/javascripts/usage_quotas/storage/utils.js49
-rw-r--r--app/assets/javascripts/users/profile/components/report_abuse_button.vue55
-rw-r--r--app/assets/javascripts/users/profile/index.js18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue23
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue58
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/state_container.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue21
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue22
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue41
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/constants.js27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js34
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/graphql/security_report_merge_request_download_paths.query.graphql28
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue134
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/constants.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/group_select/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/group_select/group_select.vue91
-rw-r--r--app/assets/javascripts/vue_shared/components/group_select/init_group_selects.js48
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js42
-rw-r--r--app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue58
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue38
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/navigation_tabs.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js63
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue114
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/constants.js73
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/graphql/get_runner_platforms.query.graphql (renamed from app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql)0
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/graphql/get_runner_setup.query.graphql (renamed from app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql)0
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue123
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue169
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue201
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue15
-rw-r--r--app/assets/javascripts/vue_shared/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue5
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue2
-rw-r--r--app/assets/javascripts/work_items/components/notes/activity_filter.vue113
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note.vue59
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_body.vue37
-rw-r--r--app/assets/javascripts/work_items/components/work_item_comment_form.vue228
-rw-r--r--app/assets/javascripts/work_items/components/work_item_comment_locked.vue66
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue67
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue21
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js4
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue41
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue45
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue1
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue149
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue14
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue1
-rw-r--r--app/assets/javascripts/work_items/components/work_item_note_signed_out.vue31
-rw-r--r--app/assets/javascripts/work_items/components/work_item_notes.vue158
-rw-r--r--app/assets/javascripts/work_items/components/work_item_type_icon.vue5
-rw-r--r--app/assets/javascripts/work_items/constants.js31
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql5
-rw-r--r--app/assets/javascripts/work_items/graphql/project_work_items.query.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_links.query.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_note.fragment.graphql (renamed from app/assets/javascripts/work_items/graphql/discussion.fragment.graphql)8
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql6
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql6
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql2
-rw-r--r--app/assets/javascripts/work_items/index.js4
-rw-r--r--app/assets/stylesheets/fonts.scss29
-rw-r--r--app/assets/stylesheets/framework.scss1
-rw-r--r--app/assets/stylesheets/framework/common.scss4
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss7
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss12
-rw-r--r--app/assets/stylesheets/framework/selects.scss11
-rw-r--r--app/assets/stylesheets/framework/super_sidebar.scss22
-rw-r--r--app/assets/stylesheets/framework/variables.scss2
-rw-r--r--app/assets/stylesheets/lazy_bundles/select2_overrides.scss16
-rw-r--r--app/assets/stylesheets/page_bundles/ci_status.scss3
-rw-r--r--app/assets/stylesheets/page_bundles/editor.scss1
-rw-r--r--app/assets/stylesheets/page_bundles/import.scss45
-rw-r--r--app/assets/stylesheets/page_bundles/members.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss14
-rw-r--r--app/assets/stylesheets/page_bundles/oncall_schedules.scss10
-rw-r--r--app/assets/stylesheets/page_bundles/todos.scss4
-rw-r--r--app/assets/stylesheets/pages/ml_experiment_tracking.scss14
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss13
-rw-r--r--app/assets/stylesheets/pages/projects.scss4
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss17
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss13
-rw-r--r--app/assets/stylesheets/themes/_dark.scss4
-rw-r--r--app/assets/stylesheets/utilities.scss43
-rw-r--r--app/components/pajamas/badge_component.rb2
-rw-r--r--app/controllers/abuse_reports_controller.rb21
-rw-r--r--app/controllers/admin/application_settings/appearances_controller.rb2
-rw-r--r--app/controllers/admin/application_settings_controller.rb6
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb1
-rw-r--r--app/controllers/admin/dashboard_controller.rb1
-rw-r--r--app/controllers/admin/groups_controller.rb4
-rw-r--r--app/controllers/admin/impersonation_tokens_controller.rb4
-rw-r--r--app/controllers/admin/projects_controller.rb2
-rw-r--r--app/controllers/admin/spam_logs_controller.rb2
-rw-r--r--app/controllers/admin/topics_controller.rb6
-rw-r--r--app/controllers/admin/users_controller.rb21
-rw-r--r--app/controllers/application_controller.rb1
-rw-r--r--app/controllers/autocomplete_controller.rb2
-rw-r--r--app/controllers/concerns/access_tokens_actions.rb4
-rw-r--r--app/controllers/concerns/check_rate_limit.rb15
-rw-r--r--app/controllers/concerns/confirm_email_warning.rb11
-rw-r--r--app/controllers/concerns/content_security_policy_patch.rb27
-rw-r--r--app/controllers/concerns/enforces_two_factor_authentication.rb5
-rw-r--r--app/controllers/concerns/gitlab_recaptcha.rb2
-rw-r--r--app/controllers/concerns/integrations/actions.rb4
-rw-r--r--app/controllers/concerns/integrations/params.rb4
-rw-r--r--app/controllers/concerns/issuable_collections.rb1
-rw-r--r--app/controllers/concerns/issuable_collections_action.rb6
-rw-r--r--app/controllers/concerns/lfs_request.rb10
-rw-r--r--app/controllers/concerns/membership_actions.rb9
-rw-r--r--app/controllers/concerns/notes_actions.rb2
-rw-r--r--app/controllers/concerns/observability/content_security_policy.rb10
-rw-r--r--app/controllers/concerns/redirects_for_missing_path_on_tree.rb2
-rw-r--r--app/controllers/concerns/renders_blob.rb2
-rw-r--r--app/controllers/concerns/spammable_actions/akismet_mark_as_spam_action.rb2
-rw-r--r--app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb2
-rw-r--r--app/controllers/concerns/uploads_actions.rb2
-rw-r--r--app/controllers/concerns/verifies_with_email.rb6
-rw-r--r--app/controllers/dashboard_controller.rb2
-rw-r--r--app/controllers/groups/autocomplete_sources_controller.rb2
-rw-r--r--app/controllers/groups/imports_controller.rb3
-rw-r--r--app/controllers/groups/observability_controller.rb8
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb4
-rw-r--r--app/controllers/groups/usage_quotas_controller.rb3
-rw-r--r--app/controllers/groups_controller.rb6
-rw-r--r--app/controllers/ide_controller.rb1
-rw-r--r--app/controllers/import/available_namespaces_controller.rb10
-rw-r--r--app/controllers/import/bitbucket_controller.rb4
-rw-r--r--app/controllers/import/bitbucket_server_controller.rb4
-rw-r--r--app/controllers/import/bulk_imports_controller.rb23
-rw-r--r--app/controllers/import/github_controller.rb19
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb8
-rw-r--r--app/controllers/profiles/avatars_controller.rb2
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb2
-rw-r--r--app/controllers/projects/artifacts_controller.rb1
-rw-r--r--app/controllers/projects/autocomplete_sources_controller.rb4
-rw-r--r--app/controllers/projects/ci/pipeline_editor_controller.rb3
-rw-r--r--app/controllers/projects/design_management/designs/resized_image_controller.rb13
-rw-r--r--app/controllers/projects/environments_controller.rb10
-rw-r--r--app/controllers/projects/google_cloud/deployments_controller.rb7
-rw-r--r--app/controllers/projects/group_links_controller.rb21
-rw-r--r--app/controllers/projects/incidents_controller.rb1
-rw-r--r--app/controllers/projects/issues_controller.rb12
-rw-r--r--app/controllers/projects/merge_requests/application_controller.rb6
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb34
-rw-r--r--app/controllers/projects/merge_requests_controller.rb22
-rw-r--r--app/controllers/projects/ml/experiments_controller.rb26
-rw-r--r--app/controllers/projects/pages_controller.rb10
-rw-r--r--app/controllers/projects/protected_refs_controller.rb5
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb11
-rw-r--r--app/controllers/projects/settings/merge_requests_controller.rb2
-rw-r--r--app/controllers/projects/work_items_controller.rb2
-rw-r--r--app/controllers/projects_controller.rb4
-rw-r--r--app/controllers/pwa_controller.rb2
-rw-r--r--app/controllers/registrations/welcome_controller.rb38
-rw-r--r--app/controllers/registrations_controller.rb10
-rw-r--r--app/controllers/search_controller.rb37
-rw-r--r--app/controllers/sessions_controller.rb4
-rw-r--r--app/controllers/snippets/notes_controller.rb2
-rw-r--r--app/controllers/uploads_controller.rb5
-rw-r--r--app/events/pages_domains/pages_domain_created_event.rb1
-rw-r--r--app/events/pages_domains/pages_domain_deleted_event.rb1
-rw-r--r--app/events/pages_domains/pages_domain_updated_event.rb1
-rw-r--r--app/finders/access_requests_finder.rb6
-rw-r--r--app/finders/branches_finder.rb18
-rw-r--r--app/finders/ci/pipelines_finder.rb3
-rw-r--r--app/finders/ci/runners_finder.rb2
-rw-r--r--app/finders/git_refs_finder.rb2
-rw-r--r--app/finders/issuable_finder.rb8
-rw-r--r--app/finders/issuable_finder/params.rb5
-rw-r--r--app/finders/issuables/label_filter.rb44
-rw-r--r--app/finders/issues_finder.rb2
-rw-r--r--app/finders/issues_finder/params.rb8
-rw-r--r--app/finders/members_finder.rb6
-rw-r--r--app/finders/packages/go/version_finder.rb2
-rw-r--r--app/graphql/mutations/achievements/create.rb54
-rw-r--r--app/graphql/mutations/boards/lists/base_update.rb2
-rw-r--r--app/graphql/mutations/ci/job/play.rb12
-rw-r--r--app/graphql/mutations/ci/project_ci_cd_settings_update.rb2
-rw-r--r--app/graphql/mutations/clusters/agents/create.rb2
-rw-r--r--app/graphql/mutations/commits/create.rb2
-rw-r--r--app/graphql/mutations/concerns/mutations/work_items/widgetable.rb2
-rw-r--r--app/graphql/mutations/jira_import/start.rb2
-rw-r--r--app/graphql/mutations/members/groups/bulk_update.rb85
-rw-r--r--app/graphql/mutations/merge_requests/accept.rb4
-rw-r--r--app/graphql/mutations/notes/create/note.rb2
-rw-r--r--app/graphql/mutations/packages/bulk_destroy.rb5
-rw-r--r--app/graphql/mutations/releases/create.rb2
-rw-r--r--app/graphql/resolvers/ci/jobs_resolver.rb7
-rw-r--r--app/graphql/resolvers/ci/runner_groups_resolver.rb2
-rw-r--r--app/graphql/resolvers/concerns/board_item_filterable.rb1
-rw-r--r--app/graphql/resolvers/concerns/caching_array_resolver.rb1
-rw-r--r--app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb5
-rw-r--r--app/graphql/resolvers/concerns/resolves_merge_requests.rb2
-rw-r--r--app/graphql/resolvers/concerns/search_arguments.rb23
-rw-r--r--app/graphql/resolvers/issues/base_resolver.rb21
-rw-r--r--app/graphql/resolvers/issues_resolver.rb16
-rw-r--r--app/graphql/resolvers/projects/branch_rules_resolver.rb10
-rw-r--r--app/graphql/resolvers/timelog_resolver.rb36
-rw-r--r--app/graphql/resolvers/work_items_resolver.rb2
-rw-r--r--app/graphql/types/access_level_enum.rb2
-rw-r--r--app/graphql/types/achievements/achievement_type.rb55
-rw-r--r--app/graphql/types/ci/runner_countable_connection_type.rb12
-rw-r--r--app/graphql/types/ci/runner_type.rb9
-rw-r--r--app/graphql/types/description_version_type.rb19
-rw-r--r--app/graphql/types/issue_type.rb9
-rw-r--r--app/graphql/types/issues/unioned_issue_filter_input_type.rb3
-rw-r--r--app/graphql/types/member_access_level_enum.rb16
-rw-r--r--app/graphql/types/member_interface.rb2
-rw-r--r--app/graphql/types/merge_requests/interacts_with_merge_request.rb2
-rw-r--r--app/graphql/types/mutation_type.rb2
-rw-r--r--app/graphql/types/namespace/shared_runners_setting_enum.rb13
-rw-r--r--app/graphql/types/namespace_type.rb11
-rw-r--r--app/graphql/types/notes/note_type.rb94
-rw-r--r--app/graphql/types/notes/noteable_interface.rb5
-rw-r--r--app/graphql/types/notes/system_note_metadata_type.rb22
-rw-r--r--app/graphql/types/projects/branch_rule_type.rb16
-rw-r--r--app/graphql/types/query_type.rb184
-rw-r--r--app/graphql/types/repository/blob_type.rb8
-rw-r--r--app/graphql/types/time_tracking/timelog_connection_type.rb25
-rw-r--r--app/graphql/types/time_tracking/timelog_sort_enum.rb21
-rw-r--r--app/graphql/types/timelog_type.rb2
-rw-r--r--app/graphql/types/todo_action_enum.rb2
-rw-r--r--app/graphql/types/user_interface.rb13
-rw-r--r--app/graphql/types/users/email_type.rb36
-rw-r--r--app/graphql/types/users/namespace_commit_email_type.rb36
-rw-r--r--app/graphql/types/work_items/widgets/description_type.rb4
-rw-r--r--app/helpers/admin/components_helper.rb15
-rw-r--r--app/helpers/appearances_helper.rb4
-rw-r--r--app/helpers/application_helper.rb20
-rw-r--r--app/helpers/application_settings_helper.rb5
-rw-r--r--app/helpers/broadcast_messages_helper.rb8
-rw-r--r--app/helpers/button_helper.rb4
-rw-r--r--app/helpers/ci/runners_helper.rb2
-rw-r--r--app/helpers/emails_helper.rb35
-rw-r--r--app/helpers/environment_helper.rb5
-rw-r--r--app/helpers/feature_flags_helper.rb3
-rw-r--r--app/helpers/form_helper.rb12
-rw-r--r--app/helpers/graph_helper.rb2
-rw-r--r--app/helpers/groups/group_members_helper.rb4
-rw-r--r--app/helpers/groups/observability_helper.rb10
-rw-r--r--app/helpers/import_helper.rb2
-rw-r--r--app/helpers/integrations_helper.rb8
-rw-r--r--app/helpers/issuables_helper.rb22
-rw-r--r--app/helpers/issues_helper.rb28
-rw-r--r--app/helpers/markup_helper.rb15
-rw-r--r--app/helpers/merge_requests_helper.rb6
-rw-r--r--app/helpers/nav_helper.rb4
-rw-r--r--app/helpers/preferences_helper.rb17
-rw-r--r--app/helpers/projects/ml/experiments_helper.rb23
-rw-r--r--app/helpers/projects/project_members_helper.rb4
-rw-r--r--app/helpers/projects_helper.rb30
-rw-r--r--app/helpers/protected_branches_helper.rb19
-rw-r--r--app/helpers/search_helper.rb2
-rw-r--r--app/helpers/selects_helper.rb14
-rw-r--r--app/helpers/sidebars_helper.rb12
-rw-r--r--app/helpers/timeboxes_helper.rb14
-rw-r--r--app/helpers/todos_helper.rb4
-rw-r--r--app/helpers/url_helper.rb11
-rw-r--r--app/helpers/users/callouts_helper.rb4
-rw-r--r--app/helpers/users_helper.rb2
-rw-r--r--app/helpers/version_check_helper.rb2
-rw-r--r--app/helpers/visibility_level_helper.rb2
-rw-r--r--app/helpers/work_items_helper.rb4
-rw-r--r--app/mailers/devise_mailer.rb5
-rw-r--r--app/mailers/emails/imports.rb15
-rw-r--r--app/mailers/emails/profile.rb5
-rw-r--r--app/mailers/emails/service_desk.rb33
-rw-r--r--app/mailers/notify.rb1
-rw-r--r--app/mailers/previews/notify_preview.rb4
-rw-r--r--app/models/abuse_report.rb37
-rw-r--r--app/models/achievements/achievement.rb3
-rw-r--r--app/models/achievements/user_achievement.rb17
-rw-r--r--app/models/analytics/cycle_analytics/aggregation.rb8
-rw-r--r--app/models/analytics/cycle_analytics/project_stage.rb5
-rw-r--r--app/models/appearance.rb8
-rw-r--r--app/models/application_setting.rb17
-rw-r--r--app/models/application_setting_implementation.rb4
-rw-r--r--app/models/bulk_imports/entity.rb4
-rw-r--r--app/models/chat_name.rb4
-rw-r--r--app/models/ci/artifact_blob.rb2
-rw-r--r--app/models/ci/bridge.rb31
-rw-r--r--app/models/ci/build.rb26
-rw-r--r--app/models/ci/build_metadata.rb2
-rw-r--r--app/models/ci/build_trace_chunk.rb2
-rw-r--r--app/models/ci/job_artifact.rb8
-rw-r--r--app/models/ci/pipeline.rb6
-rw-r--r--app/models/ci/runner.rb23
-rw-r--r--app/models/ci/runner_machine.rb33
-rw-r--r--app/models/clusters/concerns/provider_status.rb2
-rw-r--r--app/models/clusters/providers/aws.rb12
-rw-r--r--app/models/clusters/providers/gcp.rb6
-rw-r--r--app/models/commit.rb14
-rw-r--r--app/models/commit_collection.rb2
-rw-r--r--app/models/commit_signatures/ssh_signature.rb7
-rw-r--r--app/models/commit_status.rb1
-rw-r--r--app/models/concerns/analytics/cycle_analytics/parentable.rb22
-rw-r--r--app/models/concerns/analytics/cycle_analytics/stageable.rb (renamed from app/models/concerns/analytics/cycle_analytics/stage.rb)68
-rw-r--r--app/models/concerns/board_recent_visit.rb4
-rw-r--r--app/models/concerns/ci/has_runner_executor.rb24
-rw-r--r--app/models/concerns/counter_attribute.rb40
-rw-r--r--app/models/concerns/has_user_type.rb22
-rw-r--r--app/models/concerns/noteable.rb12
-rw-r--r--app/models/concerns/project_features_compatibility.rb9
-rw-r--r--app/models/concerns/resolvable_discussion.rb2
-rw-r--r--app/models/concerns/safely_change_column_default.rb46
-rw-r--r--app/models/concerns/update_project_statistics.rb5
-rw-r--r--app/models/concerns/work_item_resource_event.rb23
-rw-r--r--app/models/deploy_key.rb8
-rw-r--r--app/models/deployment.rb11
-rw-r--r--app/models/description_version.rb2
-rw-r--r--app/models/environment.rb21
-rw-r--r--app/models/event.rb2
-rw-r--r--app/models/group.rb16
-rw-r--r--app/models/integration.rb4
-rw-r--r--app/models/integrations/apple_app_store.rb111
-rw-r--r--app/models/integrations/base_chat_notification.rb16
-rw-r--r--app/models/integrations/base_slash_commands.rb2
-rw-r--r--app/models/integrations/chat_message/issue_message.rb10
-rw-r--r--app/models/integrations/chat_message/pipeline_message.rb2
-rw-r--r--app/models/integrations/field.rb7
-rw-r--r--app/models/integrations/flowdock.rb20
-rw-r--r--app/models/issue.rb7
-rw-r--r--app/models/label_note.rb15
-rw-r--r--app/models/member.rb6
-rw-r--r--app/models/members/member_role.rb8
-rw-r--r--app/models/merge_request.rb18
-rw-r--r--app/models/merge_request_diff.rb13
-rw-r--r--app/models/milestone.rb2
-rw-r--r--app/models/ml/candidate.rb23
-rw-r--r--app/models/namespace.rb20
-rw-r--r--app/models/namespace_setting.rb6
-rw-r--r--app/models/note.rb14
-rw-r--r--app/models/packages/nuget.rb1
-rw-r--r--app/models/packages/package.rb4
-rw-r--r--app/models/packages/package_file.rb1
-rw-r--r--app/models/pages/lookup_path.rb2
-rw-r--r--app/models/pages_domain.rb6
-rw-r--r--app/models/personal_access_token.rb20
-rw-r--r--app/models/project.rb73
-rw-r--r--app/models/project_setting.rb2
-rw-r--r--app/models/project_statistics.rb29
-rw-r--r--app/models/projects/branch_rule.rb32
-rw-r--r--app/models/projects/build_artifacts_size_refresh.rb44
-rw-r--r--app/models/protected_branch.rb14
-rw-r--r--app/models/protected_branch/merge_access_level.rb1
-rw-r--r--app/models/protected_branch/push_access_level.rb1
-rw-r--r--app/models/protected_tag/create_access_level.rb1
-rw-r--r--app/models/release.rb3
-rw-r--r--app/models/repository.rb31
-rw-r--r--app/models/resource_event.rb6
-rw-r--r--app/models/resource_label_event.rb5
-rw-r--r--app/models/resource_milestone_event.rb4
-rw-r--r--app/models/resource_state_event.rb5
-rw-r--r--app/models/resource_timebox_event.rb5
-rw-r--r--app/models/synthetic_note.rb2
-rw-r--r--app/models/system_note_metadata.rb6
-rw-r--r--app/models/timelog.rb11
-rw-r--r--app/models/todo.rb14
-rw-r--r--app/models/user.rb61
-rw-r--r--app/models/user_custom_attribute.rb3
-rw-r--r--app/models/user_detail.rb31
-rw-r--r--app/models/users/namespace_commit_email.rb18
-rw-r--r--app/models/work_item.rb2
-rw-r--r--app/models/work_items/parent_link.rb10
-rw-r--r--app/models/work_items/widgets/hierarchy.rb2
-rw-r--r--app/policies/achievements/achievement_policy.rb7
-rw-r--r--app/policies/ci/build_policy.rb6
-rw-r--r--app/policies/ci/job_artifact_policy.rb15
-rw-r--r--app/policies/description_version_policy.rb5
-rw-r--r--app/policies/email_policy.rb5
-rw-r--r--app/policies/group_policy.rb2
-rw-r--r--app/policies/issuable_policy.rb5
-rw-r--r--app/policies/issue_policy.rb11
-rw-r--r--app/policies/merge_request_policy.rb4
-rw-r--r--app/policies/note_policy.rb10
-rw-r--r--app/policies/project_group_link_policy.rb17
-rw-r--r--app/policies/project_policy.rb1
-rw-r--r--app/policies/projects/branch_rule_policy.rb8
-rw-r--r--app/policies/resource_event_policy.rb5
-rw-r--r--app/policies/resource_label_event_policy.rb4
-rw-r--r--app/policies/resource_milestone_event_policy.rb14
-rw-r--r--app/policies/resource_state_event_policy.rb10
-rw-r--r--app/policies/todo_policy.rb10
-rw-r--r--app/policies/user_policy.rb1
-rw-r--r--app/policies/users/namespace_commit_email_policy.rb7
-rw-r--r--app/presenters/ci/build_runner_presenter.rb5
-rw-r--r--app/presenters/packages/nuget/packages_metadata_presenter.rb10
-rw-r--r--app/presenters/packages/nuget/presenter_helpers.rb6
-rw-r--r--app/presenters/project_presenter.rb3
-rw-r--r--app/serializers/ci/downloadable_artifact_entity.rb2
-rw-r--r--app/serializers/ci/pipeline_entity.rb2
-rw-r--r--app/serializers/diffs_entity.rb2
-rw-r--r--app/serializers/diffs_metadata_entity.rb2
-rw-r--r--app/serializers/group_link/group_group_link_entity.rb4
-rw-r--r--app/serializers/group_link/group_link_entity.rb2
-rw-r--r--app/serializers/group_link/project_group_link_entity.rb4
-rw-r--r--app/serializers/issue_entity.rb2
-rw-r--r--app/serializers/merge_requests/pipeline_entity.rb4
-rw-r--r--app/serializers/paginated_diff_entity.rb2
-rw-r--r--app/serializers/remote_mirror_entity.rb2
-rw-r--r--app/services/achievements/base_service.rb20
-rw-r--r--app/services/achievements/create_service.rb25
-rw-r--r--app/services/boards/base_items_list_service.rb6
-rw-r--r--app/services/bulk_imports/create_service.rb13
-rw-r--r--app/services/captcha/captcha_verification_service.rb4
-rw-r--r--app/services/chat_names/authorize_user_service.rb4
-rw-r--r--app/services/ci/create_pipeline_service.rb7
-rw-r--r--app/services/ci/job_artifacts/create_service.rb10
-rw-r--r--app/services/ci/job_artifacts/delete_service.rb3
-rw-r--r--app/services/ci/job_artifacts/destroy_associations_service.rb17
-rw-r--r--app/services/ci/job_artifacts/destroy_batch_service.rb22
-rw-r--r--app/services/clusters/aws/authorize_role_service.rb74
-rw-r--r--app/services/clusters/aws/fetch_credentials_service.rb80
-rw-r--r--app/services/clusters/aws/finalize_creation_service.rb139
-rw-r--r--app/services/clusters/aws/provision_service.rb85
-rw-r--r--app/services/clusters/aws/verify_provision_status_service.rb50
-rw-r--r--app/services/clusters/create_service.rb4
-rw-r--r--app/services/clusters/gcp/fetch_operation_service.rb31
-rw-r--r--app/services/clusters/gcp/finalize_creation_service.rb127
-rw-r--r--app/services/clusters/gcp/provision_service.rb56
-rw-r--r--app/services/clusters/gcp/verify_provision_status_service.rb50
-rw-r--r--app/services/concerns/integrations/project_test_data.rb24
-rw-r--r--app/services/design_management/save_designs_service.rb2
-rw-r--r--app/services/discussions/resolve_service.rb15
-rw-r--r--app/services/discussions/unresolve_service.rb17
-rw-r--r--app/services/draft_notes/publish_service.rb13
-rw-r--r--app/services/environments/stop_stale_service.rb24
-rw-r--r--app/services/files/base_service.rb15
-rw-r--r--app/services/git/branch_hooks_service.rb9
-rw-r--r--app/services/groups/import_export/export_service.rb14
-rw-r--r--app/services/groups/import_export/import_service.rb34
-rw-r--r--app/services/groups/transfer_service.rb5
-rw-r--r--app/services/ide/schemas_config_service.rb4
-rw-r--r--app/services/import/github/gists_import_service.rb10
-rw-r--r--app/services/import/github_service.rb14
-rw-r--r--app/services/integrations/test/base_service.rb4
-rw-r--r--app/services/issuable/discussions_list_service.rb3
-rw-r--r--app/services/issuable_links/create_service.rb2
-rw-r--r--app/services/issues/base_service.rb12
-rw-r--r--app/services/issues/create_service.rb13
-rw-r--r--app/services/lfs/file_transformer.rb16
-rw-r--r--app/services/members/approve_access_request_service.rb2
-rw-r--r--app/services/members/creator_service.rb2
-rw-r--r--app/services/members/destroy_service.rb44
-rw-r--r--app/services/members/update_service.rb59
-rw-r--r--app/services/merge_requests/base_service.rb12
-rw-r--r--app/services/merge_requests/rebase_service.rb21
-rw-r--r--app/services/merge_requests/refresh_service.rb4
-rw-r--r--app/services/merge_requests/update_service.rb2
-rw-r--r--app/services/ml/experiment_tracking/candidate_repository.rb10
-rw-r--r--app/services/notes/build_service.rb2
-rw-r--r--app/services/notes/create_service.rb22
-rw-r--r--app/services/notification_service.rb4
-rw-r--r--app/services/pages_domains/create_service.rb1
-rw-r--r--app/services/pages_domains/delete_service.rb1
-rw-r--r--app/services/pages_domains/retry_acme_order_service.rb1
-rw-r--r--app/services/pages_domains/update_service.rb1
-rw-r--r--app/services/personal_access_tokens/revoke_service.rb19
-rw-r--r--app/services/projects/autocomplete_service.rb2
-rw-r--r--app/services/projects/create_service.rb6
-rw-r--r--app/services/projects/import_service.rb2
-rw-r--r--app/services/projects/refresh_build_artifacts_size_statistics_service.rb19
-rw-r--r--app/services/repositories/housekeeping_service.rb20
-rw-r--r--app/services/search_service.rb19
-rw-r--r--app/services/security/ci_configuration/base_create_service.rb23
-rw-r--r--app/services/security/ci_configuration/container_scanning_create_service.rb4
-rw-r--r--app/services/security/ci_configuration/dependency_scanning_create_service.rb4
-rw-r--r--app/services/security/ci_configuration/sast_create_service.rb12
-rw-r--r--app/services/security/ci_configuration/sast_iac_create_service.rb4
-rw-r--r--app/services/security/ci_configuration/secret_detection_create_service.rb4
-rw-r--r--app/services/service_ping/submit_service.rb8
-rw-r--r--app/services/service_response.rb29
-rw-r--r--app/services/test_hooks/base_service.rb7
-rw-r--r--app/services/todo_service.rb9
-rw-r--r--app/services/users/block_service.rb8
-rw-r--r--app/services/users/signup_service.rb4
-rw-r--r--app/services/users/unblock_service.rb29
-rw-r--r--app/services/work_items/parent_links/create_service.rb2
-rw-r--r--app/uploaders/object_storage.rb1
-rw-r--r--app/validators/json_schemas/cyclonedx_report.json1075
-rw-r--r--app/views/abuse_reports/new.html.haml44
-rw-r--r--app/views/admin/application_settings/_kroki.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_check.html.haml30
-rw-r--r--app/views/admin/application_settings/_user_restrictions.html.haml1
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml2
-rw-r--r--app/views/admin/application_settings/ci/_header.html.haml4
-rw-r--r--app/views/admin/application_settings/ci_cd.html.haml2
-rw-r--r--app/views/admin/application_settings/general.html.haml2
-rw-r--r--app/views/admin/broadcast_messages/_form.html.haml67
-rw-r--r--app/views/admin/broadcast_messages/_table.html.haml38
-rw-r--r--app/views/admin/broadcast_messages/edit.html.haml28
-rw-r--r--app/views/admin/broadcast_messages/index.html.haml40
-rw-r--r--app/views/admin/dashboard/_stats_users_table.html.haml49
-rw-r--r--app/views/admin/dashboard/index.html.haml9
-rw-r--r--app/views/admin/dashboard/stats.html.haml66
-rw-r--r--app/views/admin/projects/show.html.haml8
-rw-r--r--app/views/admin/runners/edit.html.haml8
-rw-r--r--app/views/admin/spam_logs/_spam_log.html.haml33
-rw-r--r--app/views/admin/topics/index.html.haml1
-rw-r--r--app/views/admin/users/_access_levels.html.haml1
-rw-r--r--app/views/admin/users/_users.html.haml3
-rw-r--r--app/views/admin/users/show.html.haml4
-rw-r--r--app/views/ci/group_variables/_index.html.haml2
-rw-r--r--app/views/ci/status/_badge.html.haml2
-rw-r--r--app/views/ci/variables/_content.html.haml2
-rw-r--r--app/views/ci/variables/_index.html.haml8
-rw-r--r--app/views/clusters/clusters/_advanced_settings.html.haml7
-rw-r--r--app/views/dashboard/todos/_todo.html.haml23
-rw-r--r--app/views/devise/sessions/two_factor.html.haml4
-rw-r--r--app/views/devise/shared/_signup_box.html.haml4
-rw-r--r--app/views/devise/shared/_signup_omniauth_provider_list.haml2
-rw-r--r--app/views/errors/omniauth_error.html.haml26
-rw-r--r--app/views/groups/_delete_project_button.html.haml3
-rw-r--r--app/views/groups/_group_admin_settings.html.haml2
-rw-r--r--app/views/groups/_home_panel.html.haml17
-rw-r--r--app/views/groups/_import_group_from_another_instance_panel.html.haml8
-rw-r--r--app/views/groups/new.html.haml5
-rw-r--r--app/views/groups/settings/repository/show.html.haml2
-rw-r--r--app/views/ide/_show.html.haml2
-rw-r--r--app/views/layouts/_loading_hints.html.haml3
-rw-r--r--app/views/layouts/_page.html.haml7
-rw-r--r--app/views/layouts/_snowplow.html.haml2
-rw-r--r--app/views/layouts/application.html.haml9
-rw-r--r--app/views/layouts/dashboard.html.haml7
-rw-r--r--app/views/layouts/explore.html.haml6
-rw-r--r--app/views/layouts/header/_registration_enabled_callout.html.haml10
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml31
-rw-r--r--app/views/layouts/nav/sidebar/_your_work.html.haml1
-rw-r--r--app/views/layouts/oauth_error.html.haml72
-rw-r--r--app/views/layouts/snippets.html.haml4
-rw-r--r--app/views/notify/access_token_expired_email.html.haml12
-rw-r--r--app/views/notify/access_token_expired_email.text.erb10
-rw-r--r--app/views/notify/access_token_revoked_email.html.haml2
-rw-r--r--app/views/notify/access_token_revoked_email.text.erb2
-rw-r--r--app/views/notify/github_gists_import_errors_email.html.haml19
-rw-r--r--app/views/notify/github_gists_import_errors_email.text.erb12
-rw-r--r--app/views/notify/service_desk_new_note_email.html.haml2
-rw-r--r--app/views/profiles/chat_names/_chat_name.html.haml10
-rw-r--r--app/views/profiles/notifications/_email_settings.html.haml3
-rw-r--r--app/views/profiles/notifications/_group_settings.html.haml2
-rw-r--r--app/views/profiles/preferences/show.html.haml24
-rw-r--r--app/views/projects/_files.html.haml4
-rw-r--r--app/views/projects/_fork_info.html.haml14
-rw-r--r--app/views/projects/_home_panel.html.haml4
-rw-r--r--app/views/projects/_import_project_pane.html.haml8
-rw-r--r--app/views/projects/artifacts/index.html.haml4
-rw-r--r--app/views/projects/blob/_template_selectors.html.haml3
-rw-r--r--app/views/projects/branch_defaults/_branch_names_fields.html.haml2
-rw-r--r--app/views/projects/buttons/_fork.html.haml2
-rw-r--r--app/views/projects/ci/builds/_build.html.haml2
-rw-r--r--app/views/projects/commit/_multiple_signatures_signature_badge.html.haml7
-rw-r--r--app/views/projects/commit/_other_user_signature_badge.html.haml7
-rw-r--r--app/views/projects/commit/_same_user_different_email_signature_badge.html.haml7
-rw-r--r--app/views/projects/commit/_signature_badge.html.haml24
-rw-r--r--app/views/projects/commit/_unverified_signature_badge.html.haml7
-rw-r--r--app/views/projects/commit/_verified_signature_badge.html.haml7
-rw-r--r--app/views/projects/commits/show.html.haml2
-rw-r--r--app/views/projects/feature_flags/new.html.haml2
-rw-r--r--app/views/projects/forks/new.html.haml1
-rw-r--r--app/views/projects/imports/new.html.haml2
-rw-r--r--app/views/projects/issues/_work_item_links.html.haml6
-rw-r--r--app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml7
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml1
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml2
-rw-r--r--app/views/projects/merge_requests/_page.html.haml2
-rw-r--r--app/views/projects/milestones/show.html.haml1
-rw-r--r--app/views/projects/ml/experiments/show.html.haml4
-rw-r--r--app/views/projects/pages/new.html.haml18
-rw-r--r--app/views/projects/pipeline_schedules/index.html.haml2
-rw-r--r--app/views/projects/pipelines/_info.html.haml4
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml5
-rw-r--r--app/views/projects/settings/repository/_protected_branches.html.haml2
-rw-r--r--app/views/projects/settings/repository/show.html.haml2
-rw-r--r--app/views/projects/triggers/_index.html.haml27
-rw-r--r--app/views/projects/triggers/_trigger.html.haml37
-rw-r--r--app/views/protected_branches/_branches_list.html.haml6
-rw-r--r--app/views/protected_branches/_create_protected_branch.html.haml2
-rw-r--r--app/views/protected_branches/_index.html.haml6
-rw-r--r--app/views/protected_branches/_protected_branch.html.haml2
-rw-r--r--app/views/protected_branches/shared/_branches_list.html.haml6
-rw-r--r--app/views/protected_branches/shared/_create_protected_branch.html.haml15
-rw-r--r--app/views/protected_branches/shared/_index.html.haml3
-rw-r--r--app/views/protected_branches/shared/_protected_branch.html.haml26
-rw-r--r--app/views/pwa/manifest.json.erb2
-rw-r--r--app/views/search/_category.html.haml2
-rw-r--r--app/views/search/_results.html.haml8
-rw-r--r--app/views/search/_results_list.html.haml2
-rw-r--r--app/views/search/_results_status.html.haml8
-rw-r--r--app/views/search/_results_status_horiz_nav.html.haml20
-rw-r--r--app/views/search/_results_status_vert_nav.html.haml20
-rw-r--r--app/views/search/show.html.haml4
-rw-r--r--app/views/shared/_broadcast_message.html.haml2
-rw-r--r--app/views/shared/_milestone_expired.html.haml7
-rw-r--r--app/views/shared/_milestones_sort_dropdown.html.haml2
-rw-r--r--app/views/shared/_ref_dropdown.html.haml7
-rw-r--r--app/views/shared/admin/_admin_note.html.haml2
-rw-r--r--app/views/shared/doorkeeper/applications/_delete_form.html.haml6
-rw-r--r--app/views/shared/issue_type/_details_content.html.haml6
-rw-r--r--app/views/shared/milestones/_description.html.haml5
-rw-r--r--app/views/shared/milestones/_header.html.haml3
-rw-r--r--app/views/shared/nav/_your_work_scope_header.html.haml6
-rw-r--r--app/views/shared/projects/_list.html.haml2
-rw-r--r--app/views/shared/runners/_runner_type_alert.html.haml2
-rw-r--r--app/views/snippets/edit.html.haml2
-rw-r--r--app/views/snippets/new.html.haml3
-rw-r--r--app/views/snippets/show.html.haml1
-rw-r--r--app/views/users/calendar_activities.html.haml2
-rw-r--r--app/views/users/show.html.haml9
-rw-r--r--app/workers/all_queues.yml66
-rw-r--r--app/workers/chat_notification_worker.rb2
-rw-r--r--app/workers/ci/build_finished_worker.rb1
-rw-r--r--app/workers/ci/initial_pipeline_process_worker.rb12
-rw-r--r--app/workers/cluster_provision_worker.rb16
-rw-r--r--app/workers/concerns/application_worker.rb1
-rw-r--r--app/workers/concerns/git_garbage_collect_methods.rb42
-rw-r--r--app/workers/concerns/update_repository_storage_worker.rb1
-rw-r--r--app/workers/concerns/worker_attributes.rb7
-rw-r--r--app/workers/counters/cleanup_refresh_worker.rb31
-rw-r--r--app/workers/create_note_diff_file_worker.rb2
-rw-r--r--app/workers/database/batched_background_migration/single_database_worker.rb1
-rw-r--r--app/workers/delete_diff_files_worker.rb2
-rw-r--r--app/workers/gitlab/github_gists_import/finish_import_worker.rb14
-rw-r--r--app/workers/gitlab/github_gists_import/import_gist_worker.rb24
-rw-r--r--app/workers/issues/rebalancing_worker.rb2
-rw-r--r--app/workers/merge_request_cleanup_refs_worker.rb2
-rw-r--r--app/workers/merge_request_mergeability_check_worker.rb2
-rw-r--r--app/workers/merge_requests/close_issue_worker.rb2
-rw-r--r--app/workers/merge_requests/create_approval_event_worker.rb2
-rw-r--r--app/workers/merge_requests/create_approval_note_worker.rb2
-rw-r--r--app/workers/merge_requests/create_pipeline_worker.rb8
-rw-r--r--app/workers/merge_requests/execute_approval_hooks_worker.rb2
-rw-r--r--app/workers/merge_requests/handle_assignees_change_worker.rb2
-rw-r--r--app/workers/merge_requests/resolve_todos_after_approval_worker.rb2
-rw-r--r--app/workers/merge_requests/resolve_todos_worker.rb2
-rw-r--r--app/workers/merge_requests/update_head_pipeline_worker.rb2
-rw-r--r--app/workers/migrate_external_diffs_worker.rb2
-rw-r--r--app/workers/new_merge_request_worker.rb2
-rw-r--r--app/workers/pages/invalidate_domain_cache_worker.rb12
-rw-r--r--app/workers/personal_access_tokens/expired_notification_worker.rb18
-rw-r--r--app/workers/pipeline_schedule_worker.rb43
-rw-r--r--app/workers/projects/delete_branch_worker.rb6
-rw-r--r--app/workers/projects/finalize_project_statistics_refresh_worker.rb33
-rw-r--r--app/workers/projects/git_garbage_collect_worker.rb6
-rw-r--r--app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb12
-rw-r--r--app/workers/remove_unreferenced_lfs_objects_worker.rb2
-rw-r--r--app/workers/repository_import_worker.rb7
-rw-r--r--app/workers/run_pipeline_schedule_worker.rb14
-rw-r--r--app/workers/schedule_merge_request_cleanup_refs_worker.rb2
-rw-r--r--app/workers/schedule_migrate_external_diffs_worker.rb2
-rw-r--r--app/workers/stuck_merge_jobs_worker.rb2
-rw-r--r--app/workers/update_merge_requests_worker.rb2
-rw-r--r--app/workers/wait_for_cluster_creation_worker.rb16
-rw-r--r--app/workers/wikis/git_garbage_collect_worker.rb6
1036 files changed, 12612 insertions, 6076 deletions
diff --git a/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue b/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue
new file mode 100644
index 00000000000..c716afbbcf0
--- /dev/null
+++ b/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue
@@ -0,0 +1,112 @@
+<script>
+import { GlButton, GlDrawer, GlForm, GlFormGroup, GlFormRadioGroup } from '@gitlab/ui';
+import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
+import { s__, __ } from '~/locale';
+import csrf from '~/lib/utils/csrf';
+
+export default {
+ name: 'AbuseCategorySelector',
+ csrf,
+ components: {
+ GlButton,
+ GlDrawer,
+ GlForm,
+ GlFormGroup,
+ GlFormRadioGroup,
+ },
+ inject: {
+ reportAbusePath: {
+ default: '',
+ },
+ reportedUserId: {
+ default: '',
+ },
+ reportedFromUrl: {
+ default: '',
+ },
+ },
+ props: {
+ showDrawer: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ i18n: {
+ title: __('Report abuse to administrator'),
+ close: __('Close'),
+ label: s__('ReportAbuse|Why are you reporting this user?'),
+ next: __('Next'),
+ },
+ categoryOptions: [
+ { value: 'spam', text: s__("ReportAbuse|They're posting spam.") },
+ { value: 'offensive', text: s__("ReportAbuse|They're being offsensive or abusive.") },
+ { value: 'phishing', text: s__("ReportAbuse|They're phising.") },
+ { value: 'crypto', text: s__("ReportAbuse|They're crypto mining.") },
+ {
+ value: 'credentials',
+ text: s__("ReportAbuse|They're posting personal information or credentials."),
+ },
+ { value: 'copyright', text: s__("ReportAbuse|They're violating a copyright or trademark.") },
+ { value: 'malware', text: s__("ReportAbuse|They're posting malware.") },
+ { value: 'other', text: s__('ReportAbuse|Something else.') },
+ ],
+ data() {
+ return {
+ selected: '',
+ };
+ },
+ computed: {
+ drawerOffsetTop() {
+ return getContentWrapperHeight('.content-wrapper');
+ },
+ },
+ methods: {
+ closeDrawer() {
+ this.$emit('close-drawer');
+ },
+ },
+};
+</script>
+<template>
+ <gl-drawer
+ :header-height="drawerOffsetTop"
+ :z-index="300"
+ :open="showDrawer"
+ @close="closeDrawer"
+ >
+ <template #title>
+ <h2
+ class="gl-font-size-h2 gl-mt-0 gl-mb-0 gl-line-height-24"
+ data-testid="category-drawer-title"
+ >
+ {{ $options.i18n.title }}
+ </h2>
+ </template>
+ <template #default>
+ <gl-form :action="reportAbusePath" method="post" class="gl-text-left">
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+
+ <input type="hidden" name="user_id" :value="reportedUserId" data-testid="input-user-id" />
+ <input
+ type="hidden"
+ name="abuse_report[reported_from_url]"
+ :value="reportedFromUrl"
+ data-testid="input-referer"
+ />
+
+ <gl-form-group :label="$options.i18n.label">
+ <gl-form-radio-group
+ v-model="selected"
+ :options="$options.categoryOptions"
+ name="abuse_report[category]"
+ required
+ />
+ </gl-form-group>
+
+ <gl-button type="submit" variant="confirm" data-testid="submit-form-button">
+ {{ $options.i18n.next }}
+ </gl-button>
+ </gl-form>
+ </template>
+ </gl-drawer>
+</template>
diff --git a/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue
index 8e814cd55ef..7cc4a0d349d 100644
--- a/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue
+++ b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue
@@ -35,7 +35,7 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-align-items-center" data-testid="database-listbox">
+ <div class="gl-display-flex gl-align-items-center">
<label id="label" class="gl-font-weight-bold gl-mr-4 gl-mb-0">{{
$options.i18n.database
}}</label>
diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue
index 55938832dce..898a688c203 100644
--- a/app/assets/javascripts/admin/users/components/actions/ban.vue
+++ b/app/assets/javascripts/admin/users/components/actions/ban.vue
@@ -11,7 +11,9 @@ const messageHtml = `
<ul>
<li>${s__("AdminUsers|The user can't log in.")}</li>
<li>${s__("AdminUsers|The user can't access git repositories.")}</li>
- <li>${s__('AdminUsers|Issues authored by this user are hidden from other users.')}</li>
+ <li>${s__(
+ 'AdminUsers|Issues and merge requests authored by this user are hidden from other users.',
+ )}</li>
</ul>
<p>${s__('AdminUsers|You can unban their account in the future. Their data remains intact.')}</p>
<p>${sprintf(
diff --git a/app/assets/javascripts/alert_handler.js b/app/assets/javascripts/alert_handler.js
index 3c867f196d6..9d53101fb22 100644
--- a/app/assets/javascripts/alert_handler.js
+++ b/app/assets/javascripts/alert_handler.js
@@ -2,10 +2,11 @@
// Note: This ONLY works on elements that are created on page load
// You can follow this effort in the following epic
// https://gitlab.com/groups/gitlab-org/-/epics/4070
+import { __ } from '~/locale';
export default function initAlertHandler() {
const DISMISSIBLE_SELECTORS = ['.gl-alert', '.gl-banner'];
- const DISMISS_LABEL = '[aria-label="Dismiss"]';
+ const DISMISS_LABEL = `[aria-label="${__('Dismiss')}"]`;
const DISMISS_CLASS = '.gl-alert-dismiss';
DISMISSIBLE_SELECTORS.forEach((selector) => {
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 65c3bc732ed..428291f2313 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
@@ -503,6 +503,7 @@ export default {
v-model="integrationForm.apiUrl"
type="text"
:placeholder="$options.placeholders.prometheus"
+ data-qa-selector="prometheus_url_field"
@input="validateApiUrl"
/>
<span class="gl-text-gray-400">
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
index 010cb5721a1..7dd33da435a 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
@@ -4,7 +4,7 @@ import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mu
import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql';
import { createAlert, VARIANT_SUCCESS } from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
-import httpStatusCodes from '~/lib/utils/http_status';
+import { HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status';
import { typeSet, i18n, tabIndices } from '../constants';
import createPrometheusIntegrationMutation from '../graphql/mutations/create_prometheus_integration.mutation.graphql';
import destroyHttpIntegrationMutation from '../graphql/mutations/destroy_http_integration.mutation.graphql';
@@ -327,7 +327,7 @@ export default {
})
.catch((error) => {
let message = INTEGRATION_PAYLOAD_TEST_ERROR;
- if (error.response?.status === httpStatusCodes.FORBIDDEN) {
+ if (error.response?.status === HTTP_STATUS_FORBIDDEN) {
message = INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR;
}
createAlert({ message });
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/metric_tile.vue b/app/assets/javascripts/analytics/cycle_analytics/components/metric_tile.vue
deleted file mode 100644
index a5c20b237b3..00000000000
--- a/app/assets/javascripts/analytics/cycle_analytics/components/metric_tile.vue
+++ /dev/null
@@ -1,51 +0,0 @@
-<script>
-import { GlSingleStat } from '@gitlab/ui/dist/charts';
-import { redirectTo } from '~/lib/utils/url_utility';
-import MetricPopover from '~/analytics/shared/components/metric_popover.vue';
-
-export default {
- name: 'MetricTile',
- components: {
- GlSingleStat,
- MetricPopover,
- },
- props: {
- metric: {
- type: Object,
- required: true,
- },
- },
- computed: {
- decimalPlaces() {
- const parsedFloat = parseFloat(this.metric.value);
- return Number.isNaN(parsedFloat) || Number.isInteger(parsedFloat) ? 0 : 1;
- },
- hasLinks() {
- return this.metric.links?.length && this.metric.links[0].url;
- },
- },
- methods: {
- clickHandler({ links }) {
- if (this.hasLinks) {
- redirectTo(links[0].url);
- }
- },
- },
-};
-</script>
-<template>
- <div v-bind="$attrs">
- <gl-single-stat
- :id="metric.identifier"
- :value="`${metric.value}`"
- :title="metric.label"
- :unit="metric.unit || ''"
- :should-animate="true"
- :animation-decimal-places="decimalPlaces"
- :class="{ 'gl-hover-cursor-pointer': hasLinks }"
- tabindex="0"
- @click="clickHandler(metric)"
- />
- <metric-popover :metric="metric" :target="metric.identifier" />
- </div>
-</template>
diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js
index 0f874e35684..45fddc3a696 100644
--- a/app/assets/javascripts/api/user_api.js
+++ b/app/assets/javascripts/api/user_api.js
@@ -65,7 +65,7 @@ export function getUserProjects(userId, query, options, callback) {
export function updateUserStatus({ emoji, message, availability, clearStatusAfter }) {
const url = buildApiUrl(USER_POST_STATUS_PATH);
- return axios.put(url, {
+ return axios.patch(url, {
emoji,
message,
availability,
diff --git a/app/assets/javascripts/artifacts/components/artifact_row.vue b/app/assets/javascripts/artifacts/components/artifact_row.vue
index 8c03db2acd1..fffdfce60a7 100644
--- a/app/assets/javascripts/artifacts/components/artifact_row.vue
+++ b/app/assets/javascripts/artifacts/components/artifact_row.vue
@@ -11,6 +11,7 @@ export default {
GlBadge,
GlFriendlyWrap,
},
+ inject: ['canDestroyArtifacts'],
props: {
artifact: {
type: Object,
@@ -73,6 +74,7 @@ export default {
data-testid="job-artifact-row-download-button"
/>
<gl-button
+ v-if="canDestroyArtifacts"
category="tertiary"
icon="remove"
:title="$options.i18n.delete"
diff --git a/app/assets/javascripts/artifacts/components/feedback_banner.vue b/app/assets/javascripts/artifacts/components/feedback_banner.vue
new file mode 100644
index 00000000000..d2c96b1a201
--- /dev/null
+++ b/app/assets/javascripts/artifacts/components/feedback_banner.vue
@@ -0,0 +1,41 @@
+<script>
+import { GlBanner } from '@gitlab/ui';
+import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
+import {
+ I18N_FEEDBACK_BANNER_TITLE,
+ I18N_FEEDBACK_BANNER_BODY,
+ I18N_FEEDBACK_BANNER_BUTTON,
+ FEEDBACK_URL,
+} from '../constants';
+
+export default {
+ components: {
+ GlBanner,
+ UserCalloutDismisser,
+ },
+ inject: ['artifactsManagementFeedbackImagePath'],
+ FEEDBACK_URL,
+ i18n: {
+ title: I18N_FEEDBACK_BANNER_TITLE,
+ body: I18N_FEEDBACK_BANNER_BODY,
+ button: I18N_FEEDBACK_BANNER_BUTTON,
+ },
+};
+</script>
+<template>
+ <user-callout-dismisser feature-name="artifacts_management_page_feedback_banner">
+ <template #default="{ dismiss, shouldShowCallout }">
+ <gl-banner
+ v-if="shouldShowCallout"
+ class="gl-mb-6"
+ :title="$options.i18n.title"
+ :button-text="$options.i18n.button"
+ :button-link="$options.FEEDBACK_URL"
+ :svg-path="artifactsManagementFeedbackImagePath"
+ @close="dismiss"
+ >
+ <p>{{ $options.i18n.body }}</p>
+ </gl-banner>
+ </template>
+ </user-callout-dismisser>
+</template>
diff --git a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/artifacts/components/job_artifacts_table.vue
index 34e443f4e58..5743ff3ec9e 100644
--- a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue
+++ b/app/assets/javascripts/artifacts/components/job_artifacts_table.vue
@@ -35,6 +35,7 @@ import {
INITIAL_LAST_PAGE_SIZE,
} from '../constants';
import ArtifactsTableRowDetails from './artifacts_table_row_details.vue';
+import FeedbackBanner from './feedback_banner.vue';
const INITIAL_PAGINATION_STATE = {
currentPage: INITIAL_CURRENT_PAGE,
@@ -58,8 +59,9 @@ export default {
CiIcon,
TimeAgo,
ArtifactsTableRowDetails,
+ FeedbackBanner,
},
- inject: ['projectPath'],
+ inject: ['projectPath', 'canDestroyArtifacts'],
apollo: {
jobArtifacts: {
query: getJobArtifactsQuery,
@@ -214,6 +216,7 @@ export default {
</script>
<template>
<div>
+ <feedback-banner />
<gl-table
:items="jobArtifacts"
:fields="$options.fields"
@@ -308,6 +311,7 @@ export default {
data-testid="job-artifacts-browse-button"
/>
<gl-button
+ v-if="canDestroyArtifacts"
icon="remove"
:title="$options.i18n.delete"
:aria-label="$options.i18n.delete"
diff --git a/app/assets/javascripts/artifacts/constants.js b/app/assets/javascripts/artifacts/constants.js
index 5fcc4f2b76e..28fd81fa641 100644
--- a/app/assets/javascripts/artifacts/constants.js
+++ b/app/assets/javascripts/artifacts/constants.js
@@ -43,6 +43,13 @@ export const I18N_MODAL_BODY = s__(
export const I18N_MODAL_PRIMARY = s__('Artifacts|Delete artifact');
export const I18N_MODAL_CANCEL = __('Cancel');
+export const I18N_FEEDBACK_BANNER_TITLE = s__('Artifacts|Help us improve this page');
+export const I18N_FEEDBACK_BANNER_BODY = s__(
+ 'Artifacts|We want you to be able to use this page to easily manage your CI/CD job artifacts. We are working to improve this experience and would appreciate any feedback you have about the improvements we are making.',
+);
+export const I18N_FEEDBACK_BANNER_BUTTON = s__('Artifacts|Take a quick survey');
+export const FEEDBACK_URL = 'https://gitlab.fra1.qualtrics.com/jfe/form/SV_cI9rAUI20Vo2St8';
+
export const INITIAL_CURRENT_PAGE = 1;
export const INITIAL_PREVIOUS_PAGE_CURSOR = '';
export const INITIAL_NEXT_PAGE_CURSOR = '';
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
index 9777153999e..89a24d7891e 100644
--- a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql
+++ b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql
@@ -10,7 +10,6 @@ query getJobArtifacts(
project(fullPath: $projectPath) {
id
jobs(
- withArtifacts: true
statuses: [SUCCESS, FAILED]
first: $firstPageSize
last: $lastPageSize
diff --git a/app/assets/javascripts/artifacts/index.js b/app/assets/javascripts/artifacts/index.js
index b5146e0f0e9..e0b2ab2bf47 100644
--- a/app/assets/javascripts/artifacts/index.js
+++ b/app/assets/javascripts/artifacts/index.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
import JobArtifactsTable from './components/job_artifacts_table.vue';
Vue.use(VueApollo);
@@ -16,13 +17,15 @@ export const initArtifactsTable = () => {
return false;
}
- const { projectPath } = el.dataset;
+ const { projectPath, canDestroyArtifacts, artifactsManagementFeedbackImagePath } = el.dataset;
return new Vue({
el,
apolloProvider,
provide: {
projectPath,
+ canDestroyArtifacts: parseBoolean(canDestroyArtifacts),
+ artifactsManagementFeedbackImagePath,
},
render: (createElement) => createElement(JobArtifactsTable),
});
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index 5ab66acaf80..2e187eae17c 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -1,56 +1,57 @@
-/* eslint-disable no-param-reassign, consistent-return */
-
+import { parseBoolean } from '~/lib/utils/common_utils';
import AccessorUtilities from './lib/utils/accessor';
export default class Autosave {
constructor(field, key, fallbackKey, lockVersion) {
this.field = field;
- this.type = this.field.prop('type');
+ this.type = this.field.getAttribute('type');
this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage();
- if (key.join != null) {
- key = key.join('/');
- }
- this.key = `autosave/${key}`;
+ this.key = Array.isArray(key) ? `autosave/${key.join('/')}` : `autosave/${key}`;
this.fallbackKey = fallbackKey;
this.lockVersionKey = `${this.key}/lockVersion`;
this.lockVersion = lockVersion;
- this.field.data('autosave', this);
this.restore();
- this.field.on('input', () => this.save());
+ this.saveAction = this.save.bind(this);
+ // used by app/assets/javascripts/deprecated_notes.js
+ this.field.$autosave = this;
+ this.field.addEventListener('input', this.saveAction);
}
restore() {
if (!this.isLocalStorageAvailable) return;
- if (!this.field.length) return;
const text = window.localStorage.getItem(this.key);
const fallbackText = window.localStorage.getItem(this.fallbackKey);
+ const newValue = text || fallbackText;
+ if (newValue == null) return;
+
+ let originalValue = this.field.value;
if (this.type === 'checkbox') {
- this.field.prop('checked', text || fallbackText);
- } else if (text) {
- this.field.val(text);
- } else if (fallbackText) {
- this.field.val(fallbackText);
+ originalValue = this.field.checked;
+ this.field.checked = parseBoolean(newValue);
+ } else {
+ this.field.value = newValue;
}
- this.field.trigger('input');
- // v-model does not update with jQuery trigger
- // https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
- const event = new Event('change', { bubbles: true, cancelable: false });
- const field = this.field.get(0);
- if (field) {
- field.dispatchEvent(event);
- }
+ if (originalValue === newValue) return;
+ this.triggerInputEvents();
+ }
+
+ triggerInputEvents() {
+ // trigger events so @input, @change and v-model trigger in Vue components
+ const inputEvent = new Event('input', { bubbles: true, cancelable: false });
+ const changeEvent = new Event('change', { bubbles: true, cancelable: false });
+ this.field.dispatchEvent(inputEvent);
+ this.field.dispatchEvent(changeEvent);
}
getSavedLockVersion() {
- if (!this.isLocalStorageAvailable) return;
+ if (!this.isLocalStorageAvailable) return undefined;
return window.localStorage.getItem(this.lockVersionKey);
}
save() {
- if (!this.field.length) return;
- const value = this.type === 'checkbox' ? this.field.is(':checked') : this.field.val();
+ const value = this.type === 'checkbox' ? this.field.checked : this.field.value;
if (this.isLocalStorageAvailable && value) {
if (this.fallbackKey) {
@@ -66,7 +67,7 @@ export default class Autosave {
}
reset() {
- if (!this.isLocalStorageAvailable) return;
+ if (!this.isLocalStorageAvailable) return undefined;
window.localStorage.removeItem(this.lockVersionKey);
window.localStorage.removeItem(this.fallbackKey);
@@ -74,7 +75,7 @@ export default class Autosave {
}
dispose() {
- // eslint-disable-next-line @gitlab/no-global-event-off
- this.field.off('input');
+ delete this.field.$autosave;
+ this.field.removeEventListener('input', this.saveAction);
}
}
diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
index acc3cbe10a0..ed0481e7a48 100644
--- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
@@ -1,5 +1,4 @@
<script>
-import $ from 'jquery';
import {
GlDropdown,
GlButton,
@@ -52,7 +51,7 @@ export default {
},
mounted() {
this.autosave = new Autosave(
- $(this.$refs.textarea),
+ this.$refs.textarea,
`submit_review_dropdown/${this.getNoteableData.id}`,
);
this.noteData.noteable_type = this.noteableType;
diff --git a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js
index d712c90242c..ff301a99243 100644
--- a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js
+++ b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js
@@ -11,6 +11,7 @@ export default function initGFMInput($els) {
emojis: true,
members: enableGFM,
issues: enableGFM,
+ iterations: enableGFM,
milestones: enableGFM,
mergeRequests: enableGFM,
labels: enableGFM,
diff --git a/app/assets/javascripts/behaviors/markdown/init_gfm.js b/app/assets/javascripts/behaviors/markdown/init_gfm.js
deleted file mode 100644
index d9c7cee50da..00000000000
--- a/app/assets/javascripts/behaviors/markdown/init_gfm.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import $ from 'jquery';
-import { renderGFM } from '~/behaviors/markdown/render_gfm';
-
-$.fn.renderGFM = function plugin() {
- this.get().forEach(renderGFM);
- return this;
-};
-requestIdleCallback(
- () => {
- renderGFM(document.body);
- },
- { timeout: 500 },
-);
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index 2eab5b84e3e..04b3599ea8c 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -18,6 +18,10 @@ function initPopovers(elements) {
// Render GitLab flavoured Markdown
export function renderGFM(element) {
+ if (!element) {
+ return;
+ }
+
const [
highlightEls,
krokiEls,
diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js
index 86a05f24dfc..32e395e4f3c 100644
--- a/app/assets/javascripts/behaviors/preview_markdown.js
+++ b/app/assets/javascripts/behaviors/preview_markdown.js
@@ -1,10 +1,10 @@
/* eslint-disable func-names */
import $ from 'jquery';
+import { renderGFM } from '~/behaviors/markdown/render_gfm';
import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
-import '~/behaviors/markdown/init_gfm';
// MarkdownPreview
//
@@ -51,7 +51,7 @@ MarkdownPreview.prototype.showPreview = function ($form) {
}
preview.removeClass('md-preview-loading').html(body);
- preview.renderGFM();
+ renderGFM(preview.get(0));
this.renderReferencedUsers(response.references.users, $form);
if (response.references.commands) {
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcut.vue b/app/assets/javascripts/behaviors/shortcuts/shortcut.vue
index e5992779a99..38384157007 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcut.vue
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcut.vue
@@ -1,4 +1,5 @@
<script>
+import { getModifierKey } from '~/constants';
import { __, s__ } from '~/locale';
// Map some keys to their proper representation depending on the system
@@ -22,7 +23,7 @@ const getKeyMap = () => {
keyMap.alt = keyMap.option;
// Mod is Command on Mac, and Ctrl on Windows/Linux
- keyMap.mod = window.gl?.client?.isMac ? keyMap.command : keyMap.ctrl;
+ keyMap.mod = getModifierKey(true);
return keyMap;
};
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
index e0ef49b60d3..7bb6bc7e9bc 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
@@ -71,7 +71,7 @@ export default class ShortcutsNavigation extends Shortcuts {
iid: window.gl.mrWidgetData?.iid,
});
if (path) {
- visitUrl(path);
+ visitUrl(path, true);
}
}
}
diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue
index 361d736f740..4e47aa99fd8 100644
--- a/app/assets/javascripts/blob/components/blob_header.vue
+++ b/app/assets/javascripts/blob/components/blob_header.vue
@@ -66,7 +66,7 @@ export default {
return !this.hideDefaultActions;
},
isEmpty() {
- return this.blob.rawSize === 0;
+ return this.blob.rawSize === '0';
},
blobSwitcherDocIcon() {
return this.blob.richViewer?.fileType === 'csv' ? 'table' : 'document';
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index adc2649e5df..2ea3c93625d 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -11,7 +11,6 @@ import DockerfileSelector from './template_selectors/dockerfile_selector';
import GitignoreSelector from './template_selectors/gitignore_selector';
import LicenseSelector from './template_selectors/license_selector';
import MetricsDashboardSelector from './template_selectors/metrics_dashboard_selector';
-import FileTemplateTypeSelector from './template_selectors/type_selector';
export default class FileTemplateMediator {
constructor({ editor, currentAction, projectId }) {
@@ -20,7 +19,6 @@ export default class FileTemplateMediator {
this.projectId = projectId;
this.initTemplateSelectors();
- this.initTemplateTypeSelector();
this.initDomElements();
this.initDropdowns();
this.initPageEvents();
@@ -38,26 +36,6 @@ export default class FileTemplateMediator {
].map((TemplateSelectorClass) => new TemplateSelectorClass({ mediator: this }));
}
- initTemplateTypeSelector() {
- this.typeSelector = new FileTemplateTypeSelector({
- mediator: this,
- dropdownData: this.templateSelectors
- .map((templateSelector) => {
- const cfg = templateSelector.config;
-
- return {
- name: cfg.name,
- key: cfg.key,
- id: cfg.key,
- };
- })
- .reduce(
- (acc, current) => (acc.find((item) => item.id === current.id) ? acc : [...acc, current]),
- [],
- ),
- });
- }
-
initDomElements() {
const $templatesMenu = $('.template-selectors-menu');
const $undoMenu = $templatesMenu.find('.template-selectors-undo-menu');
@@ -71,13 +49,10 @@ export default class FileTemplateMediator {
this.$fileContent = $fileEditor.find('#file-content');
this.$commitForm = $fileEditor.find('form');
this.$navLinks = $fileEditor.find('.nav-links');
- this.$templateTypes = this.$templateSelectors.find('.template-type-selector');
}
initDropdowns() {
- if (this.currentAction === 'create') {
- this.typeSelector.show();
- } else {
+ if (this.currentAction !== 'create') {
this.hideTemplateSelectorMenu();
}
@@ -101,32 +76,12 @@ export default class FileTemplateMediator {
const hash = urlPieces[1];
if (hash === 'preview') {
this.hideTemplateSelectorMenu();
- } else if (hash === 'editor' && !this.typeSelector.isHidden()) {
+ } else if (hash === 'editor' && this.templateSelectors.find((sel) => sel.dropdown !== null)) {
this.showTemplateSelectorMenu();
}
});
}
- selectTemplateType(item, e) {
- if (e) {
- e.preventDefault();
- }
-
- this.templateSelectors.forEach((selector) => {
- if (selector.config.key === item.key) {
- selector.show();
- } else {
- selector.hide();
- }
- });
- this.setTypeSelectorToggleText(item.name);
- this.cacheToggleText();
- }
-
- selectTemplateTypeOptions(options) {
- this.selectTemplateType(options.selectedObj, options.e);
- }
-
selectTemplateFile(selector, query, data) {
const self = this;
const { name } = selector.config;
@@ -139,7 +94,7 @@ export default class FileTemplateMediator {
this.setEditorContent(file);
this.setFilename(name);
selector.renderLoaded();
- this.typeSelector.setToggleText(name);
+
toast(__(`${query} template applied`), {
action: {
text: __('Undo'),
@@ -163,15 +118,20 @@ export default class FileTemplateMediator {
displayMatchedTemplateSelector() {
const currentInput = this.getFilename();
- this.templateSelectors.forEach((selector) => {
- const match = selector.config.pattern.test(currentInput);
-
- if (match) {
- this.typeSelector.show();
- this.selectTemplateType(selector.config);
- this.showTemplateSelectorMenu();
+ const matchedSelector = this.templateSelectors.find((sel) =>
+ sel.config.pattern.test(currentInput),
+ );
+ const currentSelector = this.templateSelectors.find((sel) => !sel.isHidden());
+
+ if (matchedSelector) {
+ if (currentSelector) {
+ currentSelector.hide();
}
- });
+ matchedSelector.show();
+ this.showTemplateSelectorMenu();
+ } else {
+ this.hideTemplateSelectorMenu();
+ }
}
fetchFileTemplate(type, query, data = {}) {
@@ -194,16 +154,13 @@ export default class FileTemplateMediator {
this.editor.navigateFileStart();
}
- findTemplateSelectorByKey(key) {
- return this.templateSelectors.find((selector) => selector.config.key === key);
- }
-
hideTemplateSelectorMenu() {
this.$templatesMenu.hide();
}
showTemplateSelectorMenu() {
this.$templatesMenu.show();
+ this.cacheToggleText();
}
cacheToggleText() {
@@ -219,7 +176,6 @@ export default class FileTemplateMediator {
this.setEditorContent(this.cachedContent);
this.setFilename(this.cachedFilename);
this.setTemplateSelectorToggleText();
- this.setTypeSelectorToggleText(__('Select a template type'));
}
getTemplateSelectorToggleText() {
@@ -234,14 +190,6 @@ export default class FileTemplateMediator {
.text(this.cachedToggleText);
}
- getTypeSelectorToggleText() {
- return this.typeSelector.getToggleText();
- }
-
- setTypeSelectorToggleText(text) {
- this.typeSelector.setToggleText(text);
- }
-
getFilename() {
return this.$filenameInput.val();
}
@@ -253,8 +201,4 @@ export default class FileTemplateMediator {
input.dispatchEvent(new Event('change'));
}
}
-
- getSelected() {
- return this.templateSelectors.find((selector) => selector.selected);
- }
}
diff --git a/app/assets/javascripts/blob/notebook/index.js b/app/assets/javascripts/blob/notebook/index.js
index 25fe29c4fbe..9259827edf1 100644
--- a/app/assets/javascripts/blob/notebook/index.js
+++ b/app/assets/javascripts/blob/notebook/index.js
@@ -1,13 +1,11 @@
import Vue from 'vue';
import NotebookViewer from './notebook_viewer.vue';
-export default () => {
- const el = document.getElementById('js-notebook-viewer');
-
+export default ({ el = document.getElementById('js-notebook-viewer'), relativeRawPath }) => {
return new Vue({
el,
provide: {
- relativeRawPath: el.dataset.relativeRawPath,
+ relativeRawPath: relativeRawPath || el.dataset.relativeRawPath,
},
render(createElement) {
return createElement(NotebookViewer, {
diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js
index 8cfdc00bb40..2386508aef5 100644
--- a/app/assets/javascripts/blob/openapi/index.js
+++ b/app/assets/javascripts/blob/openapi/index.js
@@ -15,8 +15,8 @@ const createSandbox = () => {
return iframeEl;
};
-export default async () => {
- const wrapperEl = document.getElementById('js-openapi-viewer');
+export default async (el = document.getElementById('js-openapi-viewer')) => {
+ const wrapperEl = el;
const sandboxEl = createSandbox();
const { data } = await axios.get(wrapperEl.dataset.endpoint);
diff --git a/app/assets/javascripts/blob/template_selectors/type_selector.js b/app/assets/javascripts/blob/template_selectors/type_selector.js
deleted file mode 100644
index 65e7ff0594c..00000000000
--- a/app/assets/javascripts/blob/template_selectors/type_selector.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import FileTemplateSelector from '../file_template_selector';
-
-export default class FileTemplateTypeSelector extends FileTemplateSelector {
- constructor({ mediator, dropdownData }) {
- super(mediator);
- this.mediator = mediator;
- this.config = {
- dropdown: '.js-template-type-selector',
- wrapper: '.js-template-type-selector-wrap',
- dropdownData,
- };
- }
-
- initDropdown() {
- initDeprecatedJQueryDropdown(this.$dropdown, {
- data: this.config.dropdownData,
- filterable: false,
- selectable: true,
- clicked: (options) => this.mediator.selectTemplateTypeOptions(options),
- text: (item) => item.name,
- });
- }
-}
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index 439c4258805..5e85e4cea38 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import '~/behaviors/markdown/init_gfm';
+import { renderGFM } from '~/behaviors/markdown/render_gfm';
import { createAlert } from '~/flash';
import { __ } from '~/locale';
import {
@@ -195,7 +195,7 @@ export class BlobViewer {
this.toggleCopyButtonState();
loadViewer(newViewer)
.then((viewer) => {
- $(viewer).renderGFM();
+ renderGFM(viewer);
window.requestIdleCallback(() => {
this.$fileHolder.trigger('highlight:line');
handleLocationHash();
diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index 46b3f16df77..a3d11d90ed2 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { renderGFM } from '~/behaviors/markdown/render_gfm';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext';
import { ToolbarExtension } from '~/editor/extensions/source_editor_toolbar_ext';
@@ -9,7 +10,6 @@ import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown';
import { insertFinalNewline } from '~/lib/utils/text_utility';
import TemplateSelectorMediator from '../blob/file_template_mediator';
import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants';
-import '~/behaviors/markdown/init_gfm';
export default class EditBlob {
// The options object has:
@@ -140,7 +140,7 @@ export default class EditBlob {
})
.then(({ data }) => {
currentPane.empty().append(data);
- currentPane.renderGFM();
+ renderGFM(currentPane.get(0));
})
.catch(() =>
createAlert({
diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue
index 1335a3b108b..970e3509d20 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', 'fullBoardId'],
+ inject: ['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-id="fullBoardId" />
+ <board-content :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 f3307977be9..0c64cbad5b1 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -9,6 +9,7 @@ export default {
BoardCardInner,
},
mixins: [Tracking.mixin()],
+ inject: ['disabled'],
props: {
list: {
type: Object,
@@ -20,11 +21,6 @@ export default {
default: () => ({}),
required: false,
},
- disabled: {
- type: Boolean,
- default: false,
- required: false,
- },
index: {
type: Number,
default: 0,
@@ -35,6 +31,11 @@ export default {
default: false,
required: false,
},
+ canAdmin: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
...mapState(['selectedBoardItems', 'activeId']),
@@ -48,10 +49,10 @@ export default {
);
},
isDisabled() {
- return this.disabled || !this.item.id || this.item.isLoading;
+ return this.disabled || !this.item.id || this.item.isLoading || !this.canAdmin;
},
isDraggable() {
- return !this.disabled && this.item.id && !this.item.isLoading;
+ return !this.isDisabled;
},
cardStyle() {
return this.isColorful && this.item.color ? { borderColor: this.item.color } : '';
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index 05c786ca61d..77df111afc1 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -8,7 +8,7 @@ import {
GlSprintf,
} from '@gitlab/ui';
import { sortBy } from 'lodash';
-import { mapActions, mapGetters, mapState } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { updateHistory } from '~/lib/utils/url_utility';
@@ -43,7 +43,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
mixins: [boardCardInner],
- inject: ['rootPath', 'scopedLabelsAvailable', 'isEpicBoard'],
+ inject: ['rootPath', 'scopedLabelsAvailable', 'isEpicBoard', 'issuableType', 'isGroupBoard'],
props: {
item: {
type: Object,
@@ -77,8 +77,7 @@ export default {
};
},
computed: {
- ...mapState(['isShowingLabels', 'issuableType', 'allowSubEpics']),
- ...mapGetters(['isProjectBoard']),
+ ...mapState(['isShowingLabels', 'allowSubEpics']),
cappedAssignees() {
// e.g. maxRender is 4,
// Render up to all 4 assignees if there are only 4 assigness
@@ -158,7 +157,7 @@ export default {
return Math.round((this.item.descendantWeightSum.closedIssues / this.totalWeight) * 100);
},
showReferencePath() {
- return !this.isProjectBoard && this.itemReferencePath;
+ return this.isGroupBoard && this.itemReferencePath;
},
avatarSize() {
return { default: 16, lg: 24 };
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index 8fc76c02e14..b728b8dd22a 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -20,10 +20,6 @@ export default {
default: () => ({}),
required: false,
},
- disabled: {
- type: Boolean,
- required: true,
- },
},
computed: {
...mapState(['filterParams', 'highlightedLists']),
@@ -87,8 +83,8 @@ export default {
class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-gray-50"
:class="{ 'board-column-highlighted': highlighted }"
>
- <board-list-header :list="list" :disabled="disabled" />
- <board-list ref="board-list" :disabled="disabled" :board-items="listItems" :list="list" />
+ <board-list-header :list="list" />
+ <board-list ref="board-list" :board-items="listItems" :list="list" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index ca86894ca40..92f79e61f14 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -9,7 +9,7 @@ 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, BoardType, listsQuery } from 'ee_else_ce/boards/constants';
+import { DraggableItemTypes, listsQuery } from 'ee_else_ce/boards/constants';
import BoardColumn from './board_column.vue';
export default {
@@ -35,13 +35,11 @@ export default {
'issuableType',
'isIssueBoard',
'isEpicBoard',
+ 'isGroupBoard',
+ 'disabled',
'isApolloBoard',
],
props: {
- disabled: {
- type: Boolean,
- required: true,
- },
boardId: {
type: String,
required: true,
@@ -89,8 +87,8 @@ export default {
queryVariables() {
return {
...(this.isIssueBoard && {
- isGroup: this.boardType === BoardType.group,
- isProject: this.boardType === BoardType.project,
+ isGroup: this.isGroupBoard,
+ isProject: !this.isGroupBoard,
}),
fullPath: this.fullPath,
boardId: this.boardId,
@@ -176,7 +174,6 @@ export default {
ref="board"
:list="list"
:data-draggable-item-type="$options.draggableItemTypes.list"
- :disabled="disabled"
:class="{ 'gl-xs-display-none!': addColumnFormVisible }"
/>
@@ -190,7 +187,6 @@ export default {
ref="swimlanes"
:lists="boardListsToUse"
:can-admin-list="canAdminList"
- :disabled="disabled"
:style="{ height: boardHeight }"
/>
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index 392a73b5859..e6d1e558c37 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -6,7 +6,7 @@ import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdow
import { __, sprintf } from '~/locale';
import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
-import { ISSUABLE, INCIDENT } from '~/boards/constants';
+import { BoardType, ISSUABLE, INCIDENT, issuableTypes } from '~/boards/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
@@ -65,17 +65,22 @@ export default {
canUpdate: {
default: false,
},
+ issuableType: {
+ default: issuableTypes.issue,
+ },
+ isGroupBoard: {
+ default: false,
+ },
},
inheritAttrs: false,
computed: {
...mapGetters([
- 'isGroupBoard',
'isSidebarOpen',
'activeBoardItem',
'groupPathForActiveIssue',
'projectPathForActiveIssue',
]),
- ...mapState(['sidebarType', 'issuableType']),
+ ...mapState(['sidebarType']),
isIssuableSidebar() {
return this.sidebarType === ISSUABLE;
},
@@ -91,14 +96,17 @@ export default {
fullPath() {
return this.activeBoardItem?.referencePath?.split('#')[0] || '';
},
+ parentType() {
+ return this.isGroupBoard ? BoardType.group : BoardType.project;
+ },
createLabelTitle() {
return sprintf(__('Create %{workspace} label'), {
- workspace: this.isGroupBoard ? 'group' : 'project',
+ workspace: this.parentType,
});
},
manageLabelTitle() {
return sprintf(__('Manage %{workspace} labels'), {
- workspace: this.isGroupBoard ? 'group' : 'project',
+ workspace: this.parentType,
});
},
attrWorkspacePath() {
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
index 97f52f21e7f..ce86a4d3123 100644
--- a/app/assets/javascripts/boards/components/board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -244,6 +244,13 @@ export default {
});
}
+ if (this.filterParams['not[healthStatus]']) {
+ filteredSearchValue.push({
+ type: TOKEN_TYPE_HEALTH,
+ value: { data: this.filterParams['not[healthStatus]'], operator: '!=' },
+ });
+ }
+
if (search) {
filteredSearchValue.push(search);
}
@@ -285,6 +292,7 @@ export default {
'not[my_reaction_emoji]': this.filterParams.not.myReactionEmoji,
'not[iteration_id]': this.filterParams.not.iterationId,
'not[release_tag]': this.filterParams.not.releaseTag,
+ 'not[health_status]': this.filterParams.not.healthStatus,
},
undefined,
);
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index fcf026bbe00..a71bde54a8f 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -1,6 +1,6 @@
<script>
import { GlModal, GlAlert } from '@gitlab/ui';
-import { mapGetters, mapActions, mapState } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { visitUrl, updateHistory, getParameterByName } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
@@ -51,6 +51,12 @@ export default {
boardBaseUrl: {
default: '',
},
+ isGroupBoard: {
+ default: false,
+ },
+ isProjectBoard: {
+ default: false,
+ },
},
props: {
canAdminBoard: {
@@ -84,7 +90,6 @@ export default {
},
computed: {
...mapState(['error']),
- ...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 215691c7ba2..060a708a22f 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -31,12 +31,8 @@ export default {
BoardCardMoveToPosition,
},
mixins: [Tracking.mixin()],
- inject: ['isEpicBoard'],
+ inject: ['isEpicBoard', 'disabled'],
props: {
- disabled: {
- type: Boolean,
- required: true,
- },
list: {
type: Object,
required: true,
@@ -314,7 +310,6 @@ export default {
:list="list"
:item="item"
: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 -->
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index bfc4b52baaf..14dff8de70f 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -60,6 +60,9 @@ export default {
isEpicBoard: {
default: false,
},
+ disabled: {
+ default: true,
+ },
},
props: {
list: {
@@ -67,10 +70,6 @@ export default {
default: () => ({}),
required: false,
},
- disabled: {
- type: Boolean,
- required: true,
- },
isSwimlanesHeader: {
type: Boolean,
required: false,
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index 8db366e4995..8b9fafca306 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -16,7 +16,7 @@ export default {
ProjectSelect,
},
mixins: [BoardNewIssueMixin],
- inject: ['groupId'],
+ inject: ['groupId', 'fullPath', 'isGroupBoard'],
props: {
list: {
type: Object,
@@ -24,8 +24,8 @@ export default {
},
},
computed: {
- ...mapState(['selectedProject', 'fullPath']),
- ...mapGetters(['isGroupBoard', 'getBoardItemsByList']),
+ ...mapState(['selectedProject']),
+ ...mapGetters(['getBoardItemsByList']),
formEventPrefix() {
return toggleFormEventPrefix.issue;
},
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 4f90d77c0be..d26aeb69dd5 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -9,7 +9,7 @@ import {
GlModalDirective,
} from '@gitlab/ui';
import { throttle } from 'lodash';
-import { mapActions, mapGetters, mapState } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import BoardForm from 'ee_else_ce/boards/components/board_form.vue';
@@ -49,6 +49,8 @@ export default {
'hasMissingBoards',
'scopedIssueBoardFeatureEnabled',
'weights',
+ 'boardType',
+ 'isGroupBoard',
],
props: {
throttleDuration: {
@@ -74,8 +76,7 @@ export default {
},
computed: {
- ...mapState(['boardType', 'board', 'isBoardLoading']),
- ...mapGetters(['isGroupBoard', 'isProjectBoard']),
+ ...mapState(['board', 'isBoardLoading']),
parentType() {
return this.boardType;
},
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 bc68c2e0e99..38a171e8889 100644
--- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
@@ -4,7 +4,6 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { mapActions } from 'vuex';
import { orderBy } from 'lodash';
import BoardFilteredSearch from 'ee_else_ce/boards/components/board_filtered_search.vue';
-import { BoardType } from '~/boards/constants';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import issueBoardFilters from '~/boards/issue_board_filters';
@@ -47,23 +46,15 @@ export default {
issue: __('Issue'),
},
components: { BoardFilteredSearch },
- inject: ['isSignedIn', 'releasesFetchPath', 'fullPath', 'boardType'],
+ inject: ['isSignedIn', 'releasesFetchPath', 'fullPath', 'isGroupBoard'],
computed: {
- isGroupBoard() {
- return this.boardType === BoardType.group;
- },
- epicsGroupPath() {
- return this.isGroupBoard
- ? this.fullPath
- : this.fullPath.slice(0, this.fullPath.lastIndexOf('/'));
- },
tokensCE() {
const { issue, incident } = this.$options.i18n;
const { types } = this.$options;
const { fetchUsers, fetchLabels } = issueBoardFilters(
this.$apollo,
this.fullPath,
- this.boardType,
+ this.isGroupBoard,
);
const tokens = [
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index f8bd81e6b98..968832a092d 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -3,7 +3,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import BoardApp from '~/boards/components/board_app.vue';
import '~/boards/filters/due_date_filters';
-import { issuableTypes } from '~/boards/constants';
+import { BoardType, issuableTypes } from '~/boards/constants';
import store from '~/boards/stores';
import {
NavigationType,
@@ -31,17 +31,19 @@ function mountBoardApp(el) {
...convertObjectPropsToCamelCase(rawFilterParams),
};
+ const boardType = el.dataset.parent;
+
store.dispatch('fetchBoard', {
fullPath,
fullBoardId: fullBoardId(boardId),
- boardType: el.dataset.parent,
+ boardType,
});
store.dispatch('setInitialBoardData', {
boardId,
fullBoardId: fullBoardId(boardId),
fullPath,
- boardType: el.dataset.parent,
+ boardType,
disabled: parseBoolean(el.dataset.disabled) || true,
issuableType: issuableTypes.issue,
});
@@ -61,7 +63,9 @@ function mountBoardApp(el) {
fullPath,
initialFilterParams,
boardBaseUrl: el.dataset.boardBaseUrl,
- boardType: el.dataset.parent,
+ boardType,
+ isGroupBoard: boardType === BoardType.group,
+ isProjectBoard: boardType === BoardType.project,
currentUserId: gon.current_user_id || null,
boardWeight: el.dataset.boardWeight ? parseInt(el.dataset.boardWeight, 10) : null,
labelsManagePath: el.dataset.labelsManagePath,
diff --git a/app/assets/javascripts/boards/issue_board_filters.js b/app/assets/javascripts/boards/issue_board_filters.js
index 4bfd92fb748..7e9b68778d5 100644
--- a/app/assets/javascripts/boards/issue_board_filters.js
+++ b/app/assets/javascripts/boards/issue_board_filters.js
@@ -1,11 +1,8 @@
import groupBoardMembers from '~/boards/graphql/group_board_members.query.graphql';
import projectBoardMembers from '~/boards/graphql/project_board_members.query.graphql';
-import { BoardType } from './constants';
import boardLabels from './graphql/board_labels.query.graphql';
-export default function issueBoardFilters(apollo, fullPath, boardType) {
- const isGroupBoard = boardType === BoardType.group;
- const isProjectBoard = boardType === BoardType.project;
+export default function issueBoardFilters(apollo, fullPath, isGroupBoard) {
const transformLabels = ({ data }) => {
return isGroupBoard ? data.group?.labels.nodes || [] : data.project?.labels.nodes || [];
};
@@ -34,7 +31,7 @@ export default function issueBoardFilters(apollo, fullPath, boardType) {
fullPath,
searchTerm: labelSearchTerm,
isGroup: isGroupBoard,
- isProject: isProjectBoard,
+ isProject: !isGroupBoard,
},
})
.then(transformLabels);
diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js
index e1891a4d954..9e746f1a1b8 100644
--- a/app/assets/javascripts/boards/stores/getters.js
+++ b/app/assets/javascripts/boards/stores/getters.js
@@ -1,9 +1,7 @@
import { find } from 'lodash';
-import { BoardType, inactiveId, issuableTypes } from '../constants';
+import { inactiveId, issuableTypes } from '../constants';
export default {
- isGroupBoard: (state) => state.boardType === BoardType.group,
- isProjectBoard: (state) => state.boardType === BoardType.project,
isSidebarOpen: (state) => state.activeId !== inactiveId,
isSwimlanesOn: () => false,
getBoardItemById: (state) => (id) => {
diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci/ci_variable_list/ci_variable_list.js
index 574a5e7fd99..574a5e7fd99 100644
--- a/app/assets/javascripts/ci_variable_list/ci_variable_list.js
+++ b/app/assets/javascripts/ci/ci_variable_list/ci_variable_list.js
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_admin_variables.vue
index 719696f682e..719696f682e 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_admin_variables.vue
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue
index c9002edc1ab..7387a490177 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue
@@ -1,15 +1,14 @@
<script>
-import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui';
+import { GlDropdownDivider, GlDropdownItem, GlCollapsibleListbox } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { convertEnvironmentScope } from '../utils';
export default {
name: 'CiEnvironmentsDropdown',
components: {
- GlDropdown,
- GlDropdownItem,
GlDropdownDivider,
- GlSearchBoxByType,
+ GlDropdownItem,
+ GlCollapsibleListbox,
},
props: {
environments: {
@@ -24,6 +23,7 @@ export default {
},
data() {
return {
+ selectedEnvironment: '',
searchTerm: '',
};
},
@@ -33,9 +33,15 @@ export default {
},
filteredEnvironments() {
const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
- return this.environments.filter((environment) => {
- return environment.toLowerCase().includes(lowerCasedSearchTerm);
- });
+
+ return this.environments
+ .filter((environment) => {
+ return environment.toLowerCase().includes(lowerCasedSearchTerm);
+ })
+ .map((environment) => ({
+ value: environment,
+ text: environment,
+ }));
},
shouldRenderCreateButton() {
return this.searchTerm && !this.environments.includes(this.searchTerm);
@@ -47,44 +53,29 @@ export default {
methods: {
selectEnvironment(selected) {
this.$emit('select-environment', selected);
- this.clearSearch();
- },
- convertEnvironmentScopeValue(scope) {
- return convertEnvironmentScope(scope);
+ this.selectedEnvironment = selected;
},
createEnvironmentScope() {
this.$emit('create-environment-scope', this.searchTerm);
this.selectEnvironment(this.searchTerm);
},
- isSelected(env) {
- return this.selectedEnvironmentScope === env;
- },
- clearSearch() {
- this.searchTerm = '';
- },
},
};
</script>
<template>
- <gl-dropdown :text="environmentScopeLabel" @show="clearSearch">
- <gl-search-box-by-type v-model.trim="searchTerm" data-testid="ci-environment-search" />
- <gl-dropdown-item
- v-for="environment in filteredEnvironments"
- :key="environment"
- :is-checked="isSelected(environment)"
- is-check-item
- @click="selectEnvironment(environment)"
- >
- {{ convertEnvironmentScopeValue(environment) }}
- </gl-dropdown-item>
- <gl-dropdown-item v-if="!filteredEnvironments.length" ref="noMatchingResults">{{
- __('No matching results')
- }}</gl-dropdown-item>
- <template v-if="shouldRenderCreateButton">
+ <gl-collapsible-listbox
+ v-model="selectedEnvironment"
+ searchable
+ :items="filteredEnvironments"
+ :toggle-text="environmentScopeLabel"
+ @search="searchTerm = $event.trim()"
+ @select="selectEnvironment"
+ >
+ <template v-if="shouldRenderCreateButton" #footer>
<gl-dropdown-divider />
<gl-dropdown-item data-testid="create-wildcard-button" @click="createEnvironmentScope">
{{ composedCreateButtonLabel }}
</gl-dropdown-item>
</template>
- </gl-dropdown>
+ </gl-collapsible-listbox>
</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue
index 4466a6a8081..4466a6a8081 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue
index 6326940148a..6326940148a 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_autocomplete_tokens.js
index 3f25e3df305..3f25e3df305 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_autocomplete_tokens.js
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
index 00177539cdc..967125c7b0a 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
@@ -352,7 +352,6 @@ export default {
</template>
<ci-environments-dropdown
v-if="areScopedVariablesAvailable"
- class="gl-w-full"
:selected-environment-scope="variable.environmentScope"
:environments="joinedEnvironments"
@select-environment="setEnvironmentScope"
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue
index 3c6114b38ce..3c6114b38ce 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue
index 6e39bda0b07..6e39bda0b07 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue
index 345a8def49d..345a8def49d 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue
diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js
index 828d0724d93..828d0724d93 100644
--- a/app/assets/javascripts/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci/ci_variable_list/constants.js
diff --git a/app/assets/javascripts/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql
index a28ca4eebc9..a28ca4eebc9 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql
index 9208c34f154..d6f3ddf086f 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql
@@ -1,4 +1,4 @@
-#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
mutation addAdminVariable($variable: CiVariable!, $endpoint: String!) {
ciVariableMutation: addAdminVariable(variable: $variable, endpoint: $endpoint) @client {
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql
index a79b98f5e95..c00c8fb2a26 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql
@@ -1,4 +1,4 @@
-#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
mutation deleteAdminVariable($variable: CiVariable!, $endpoint: String!) {
ciVariableMutation: deleteAdminVariable(variable: $variable, endpoint: $endpoint) @client {
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql
index ddea753bf90..d7b7cb77291 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql
@@ -1,4 +1,4 @@
-#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
mutation updateAdminVariable($variable: CiVariable!, $endpoint: String!) {
ciVariableMutation: updateAdminVariable(variable: $variable, endpoint: $endpoint) @client {
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql
index 45109762e80..45109762e80 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql
index 24388637672..0dbb6c891fd 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql
@@ -1,4 +1,4 @@
-#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
mutation addGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) {
ciVariableMutation: addGroupVariable(
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql
index f7c8e209ccd..b5d007237c8 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql
@@ -1,4 +1,4 @@
-#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
mutation deleteGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) {
ciVariableMutation: deleteGroupVariable(
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql
index 757e61a5cd3..4ffc091b490 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql
@@ -1,4 +1,4 @@
-#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
mutation updateGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) {
ciVariableMutation: updateGroupVariable(
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql
index fa315084d86..67a02be3dc1 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql
@@ -1,4 +1,4 @@
-#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
mutation addProjectVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) {
ciVariableMutation: addProjectVariable(
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql
index c3358cc35b9..4420404a7b4 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql
@@ -1,4 +1,4 @@
-#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
mutation deleteProjectVariable(
$variable: CiVariable!
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql
index fde92cef4cb..107746a19e9 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql
@@ -1,4 +1,4 @@
-#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
mutation updateProjectVariable(
$variable: CiVariable!
diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql
index 900154cd24d..538502fdd3b 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql
@@ -1,4 +1,4 @@
-#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getGroupVariables($after: String, $first: Int = 100, $fullPath: ID!) {
diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/project_environments.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql
index 921e0ca25b9..921e0ca25b9 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/queries/project_environments.query.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql
diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql
index ee75eba7547..af0cd2d0b2c 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql
@@ -1,4 +1,4 @@
-#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getProjectVariables($after: String, $first: Int = 100, $fullPath: ID!) {
diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql
index 9b255c3c182..b8dd6f5f562 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql
@@ -1,4 +1,4 @@
-#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getVariables($after: String, $first: Int = 100) {
diff --git a/app/assets/javascripts/ci_variable_list/graphql/settings.js b/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js
index 02f6c226b0f..10203383ba0 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/settings.js
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js
@@ -2,8 +2,8 @@ import axios from 'axios';
import {
convertObjectPropsToCamelCase,
convertObjectPropsToSnakeCase,
-} from '../../lib/utils/common_utils';
-import { convertToGraphQLId, getIdFromGraphQLId } from '../../graphql_shared/utils';
+} from '~/lib/utils/common_utils';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import {
GRAPHQL_GROUP_TYPE,
GRAPHQL_PROJECT_TYPE,
diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci/ci_variable_list/index.js
index 174a59aba42..174a59aba42 100644
--- a/app/assets/javascripts/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci/ci_variable_list/index.js
diff --git a/app/assets/javascripts/ci_variable_list/native_form_variable_list.js b/app/assets/javascripts/ci/ci_variable_list/native_form_variable_list.js
index fdbefd8c313..fdbefd8c313 100644
--- a/app/assets/javascripts/ci_variable_list/native_form_variable_list.js
+++ b/app/assets/javascripts/ci/ci_variable_list/native_form_variable_list.js
diff --git a/app/assets/javascripts/ci_variable_list/utils.js b/app/assets/javascripts/ci/ci_variable_list/utils.js
index eeca69274ce..eeca69274ce 100644
--- a/app/assets/javascripts/ci_variable_list/utils.js
+++ b/app/assets/javascripts/ci/ci_variable_list/utils.js
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue
index 255e3cb31f1..891c40482d3 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue
@@ -2,7 +2,6 @@
import { EDITOR_READY_EVENT } from '~/editor/constants';
import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext';
import SourceEditor from '~/vue_shared/components/source_editor.vue';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { SOURCE_EDITOR_DEBOUNCE } from '../../constants';
export default {
@@ -15,7 +14,6 @@ export default {
components: {
SourceEditor,
},
- mixins: [glFeatureFlagMixin()],
inject: ['ciConfigPath'],
inheritAttrs: false,
methods: {
@@ -23,10 +21,8 @@ export default {
this.$emit('updateCiConfig', content);
},
registerCiSchema({ detail: { instance } }) {
- if (this.glFeatures.schemaLinting) {
- instance.use({ definition: CiSchemaExtension });
- instance.registerCiSchema();
- }
+ instance.use({ definition: CiSchemaExtension });
+ instance.registerCiSchema();
},
},
readyEvent: EDITOR_READY_EVENT,
diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue
index 5692627abef..5692627abef 100644
--- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue
diff --git a/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue b/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue
new file mode 100644
index 00000000000..060527f2662
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue
@@ -0,0 +1,86 @@
+<script>
+import { GlCollapsibleListbox } from '@gitlab/ui';
+import { debounce } from 'lodash';
+import axios from '~/lib/utils/axios_utils';
+import { DEBOUNCE_REFS_SEARCH_MS } from '../constants';
+import { formatListBoxItems, searchByFullNameInListboxOptions } from '../utils/format_refs';
+
+export default {
+ components: {
+ GlCollapsibleListbox,
+ },
+ inject: ['projectRefsEndpoint'],
+ props: {
+ value: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ isLoading: false,
+ searchTerm: '',
+ listBoxItems: [],
+ };
+ },
+ computed: {
+ lowerCasedSearchTerm() {
+ return this.searchTerm.toLowerCase();
+ },
+ refShortName() {
+ return this.value.shortName;
+ },
+ },
+ methods: {
+ loadRefs() {
+ this.isLoading = true;
+
+ axios
+ .get(this.projectRefsEndpoint, {
+ params: {
+ search: this.lowerCasedSearchTerm,
+ },
+ })
+ .then(({ data }) => {
+ // Note: These keys are uppercase in API
+ const { Branches = [], Tags = [] } = data;
+
+ this.listBoxItems = formatListBoxItems(Branches, Tags);
+ })
+ .catch((e) => {
+ this.$emit('loadingError', e);
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ debouncedLoadRefs: debounce(function debouncedLoadRefs() {
+ this.loadRefs();
+ }, DEBOUNCE_REFS_SEARCH_MS),
+ setRefSelected(refFullName) {
+ const ref = searchByFullNameInListboxOptions(refFullName, this.listBoxItems);
+ this.$emit('input', ref);
+ },
+ setSearchTerm(searchQuery) {
+ this.searchTerm = searchQuery?.trim();
+ this.debouncedLoadRefs();
+ },
+ },
+};
+</script>
+<template>
+ <gl-collapsible-listbox
+ class="gl-w-full gl-font-monospace"
+ :items="listBoxItems"
+ :searchable="true"
+ :searching="isLoading"
+ :search-placeholder="__('Search refs')"
+ :selected="value.fullName"
+ toggle-class="gl-flex-direction-column gl-align-items-stretch!"
+ :toggle-text="refShortName"
+ @search="setSearchTerm"
+ @select="setRefSelected"
+ @shown.once="loadRefs"
+ />
+</template>
diff --git a/app/assets/javascripts/pipeline_new/constants.js b/app/assets/javascripts/ci/pipeline_new/constants.js
index 43f7634083b..43f7634083b 100644
--- a/app/assets/javascripts/pipeline_new/constants.js
+++ b/app/assets/javascripts/ci/pipeline_new/constants.js
diff --git a/app/assets/javascripts/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql b/app/assets/javascripts/ci/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql
index a76e8f6b95b..a76e8f6b95b 100644
--- a/app/assets/javascripts/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql
+++ b/app/assets/javascripts/ci/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql
diff --git a/app/assets/javascripts/pipeline_new/graphql/queries/ci_config_variables.graphql b/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql
index 648cd8b66b5..648cd8b66b5 100644
--- a/app/assets/javascripts/pipeline_new/graphql/queries/ci_config_variables.graphql
+++ b/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql
diff --git a/app/assets/javascripts/pipeline_new/graphql/resolvers.js b/app/assets/javascripts/ci/pipeline_new/graphql/resolvers.js
index 7b0f58e8cf9..7b0f58e8cf9 100644
--- a/app/assets/javascripts/pipeline_new/graphql/resolvers.js
+++ b/app/assets/javascripts/ci/pipeline_new/graphql/resolvers.js
diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/ci/pipeline_new/index.js
index 71c76aeab36..71c76aeab36 100644
--- a/app/assets/javascripts/pipeline_new/index.js
+++ b/app/assets/javascripts/ci/pipeline_new/index.js
diff --git a/app/assets/javascripts/pipeline_new/utils/filter_variables.js b/app/assets/javascripts/ci/pipeline_new/utils/filter_variables.js
index 57ce3d13a9a..57ce3d13a9a 100644
--- a/app/assets/javascripts/pipeline_new/utils/filter_variables.js
+++ b/app/assets/javascripts/ci/pipeline_new/utils/filter_variables.js
diff --git a/app/assets/javascripts/ci/pipeline_new/utils/format_refs.js b/app/assets/javascripts/ci/pipeline_new/utils/format_refs.js
new file mode 100644
index 00000000000..e6d26b32d47
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_new/utils/format_refs.js
@@ -0,0 +1,55 @@
+import { __ } from '~/locale';
+import { BRANCH_REF_TYPE, TAG_REF_TYPE } from '../constants';
+
+function convertToListBoxItems(items) {
+ return items.map(({ shortName, fullName }) => ({ text: shortName, value: fullName }));
+}
+
+export function formatRefs(refs, type) {
+ let fullName;
+
+ return refs.map((ref) => {
+ if (type === BRANCH_REF_TYPE) {
+ fullName = `refs/heads/${ref}`;
+ } else if (type === TAG_REF_TYPE) {
+ fullName = `refs/tags/${ref}`;
+ }
+
+ return {
+ shortName: ref,
+ fullName,
+ };
+ });
+}
+
+export const formatListBoxItems = (branches, tags) => {
+ const finalResults = [];
+
+ if (branches.length > 0) {
+ finalResults.push({
+ text: __('Branches'),
+ options: convertToListBoxItems(formatRefs(branches, BRANCH_REF_TYPE)),
+ });
+ }
+
+ if (tags.length > 0) {
+ finalResults.push({
+ text: __('Tags'),
+ options: convertToListBoxItems(formatRefs(tags, TAG_REF_TYPE)),
+ });
+ }
+
+ return finalResults;
+};
+
+export const searchByFullNameInListboxOptions = (fullName, listBox) => {
+ const optionsToSearch =
+ listBox.length > 1 ? listBox[0].options.concat(listBox[1].options) : listBox[0]?.options;
+
+ const foundOption = optionsToSearch.find(({ value }) => value === fullName);
+
+ return {
+ shortName: foundOption.text,
+ fullName: foundOption.value,
+ };
+};
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue
index fe16cb7a92e..d03de91ea07 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue
@@ -1,14 +1,25 @@
<script>
-import { GlAlert, GlBadge, GlButton, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlBadge,
+ GlButton,
+ GlLoadingIcon,
+ GlTabs,
+ GlTab,
+ GlSprintf,
+ GlLink,
+} 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 playPipelineScheduleMutation from '../graphql/mutations/play_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';
+import PipelineScheduleEmptyState from './pipeline_schedules_empty_state.vue';
export default {
i18n: {
@@ -16,11 +27,15 @@ export default {
scheduleDeleteError: s__(
'PipelineSchedules|There was a problem deleting the pipeline schedule.',
),
+ schedulePlayError: s__('PipelineSchedules|There was a problem playing 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.'),
+ playSuccess: s__(
+ 'PipelineSchedules|Successfully scheduled a pipeline to run. Go to the %{linkStart}Pipelines page%{linkEnd} for details. ',
+ ),
},
components: {
DeletePipelineScheduleModal,
@@ -30,13 +45,19 @@ export default {
GlLoadingIcon,
GlTabs,
GlTab,
+ GlSprintf,
+ GlLink,
PipelineSchedulesTable,
TakeOwnershipModal,
+ PipelineScheduleEmptyState,
},
inject: {
fullPath: {
default: '',
},
+ pipelinesPath: {
+ default: '',
+ },
},
apollo: {
schedules: {
@@ -68,6 +89,7 @@ export default {
},
scope,
hasError: false,
+ playSuccess: false,
errorMessage: '',
scheduleId: null,
showDeleteModal: false,
@@ -185,6 +207,27 @@ export default {
this.reportError(this.$options.i18n.takeOwnershipError);
}
},
+ async playPipelineSchedule(id) {
+ try {
+ const {
+ data: {
+ pipelineSchedulePlay: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: playPipelineScheduleMutation,
+ variables: { id },
+ });
+
+ if (errors.length > 0) {
+ throw new Error();
+ } else {
+ this.playSuccess = true;
+ }
+ } catch {
+ this.playSuccess = false;
+ this.reportError(this.$options.i18n.schedulePlayError);
+ }
+ },
fetchPipelineSchedulesByStatus(scope) {
this.scope = scope;
this.$apollo.queries.schedules.refetch();
@@ -195,62 +238,72 @@ export default {
<template>
<div>
- <gl-alert v-if="hasError" class="gl-mb-2" variant="danger" @dismiss="hasError = false">
+ <gl-alert v-if="hasError" class="gl-my-3" 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-alert v-if="playSuccess" class="gl-my-3" variant="info" @dismiss="playSuccess = false">
+ <gl-sprintf :message="$options.i18n.playSuccess">
+ <template #link="{ content }">
+ <gl-link :href="pipelinesPath" class="gl-text-decoration-none!">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+
+ <gl-tabs
+ v-if="isLoading || count > 0"
+ 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)"
>
- <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 #title>
+ <span>{{ tab.text }}</span>
- <template v-if="tab.showBadge">
- <gl-loading-icon v-if="tab.scope === scope && isLoading" class="gl-ml-2" />
+ <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>
+ <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>
+ <gl-loading-icon v-if="isLoading" size="lg" />
+ <pipeline-schedules-table
+ v-else
+ :schedules="schedules.list"
+ @showTakeOwnershipModal="setTakeOwnershipModal"
+ @showDeleteModal="setDeleteModal"
+ @playPipelineSchedule="playPipelineSchedule"
+ />
+ </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>
+ <template #tabs-end>
+ <gl-button variant="confirm" class="gl-ml-auto" data-testid="new-schedule-button">
+ {{ $options.i18n.newSchedule }}
+ </gl-button>
+ </template>
+ </gl-tabs>
+
+ <pipeline-schedule-empty-state v-else-if="!isLoading && count === 0" />
- <take-ownership-modal
- :visible="showTakeOwnershipModal"
- @takeOwnership="takeOwnership"
- @hideModal="hideModal"
- />
+ <take-ownership-modal
+ :visible="showTakeOwnershipModal"
+ @takeOwnership="takeOwnership"
+ @hideModal="hideModal"
+ />
- <delete-pipeline-schedule-modal
- :visible="showDeleteModal"
- @deleteSchedule="deleteSchedule"
- @hideModal="hideModal"
- />
- </template>
+ <delete-pipeline-schedule-modal
+ :visible="showDeleteModal"
+ @deleteSchedule="deleteSchedule"
+ @hideModal="hideModal"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue
new file mode 100644
index 00000000000..f633ba053ee
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue
@@ -0,0 +1,63 @@
+<script>
+import scheduleSvg from '@gitlab/svgs/dist/illustrations/schedule-md.svg';
+import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ pipelineSchedules: s__('PipelineSchedules|Pipeline schedules'),
+ description: s__(
+ 'PipelineSchedules|A scheduled pipeline starts automatically at regular intervals, like daily or weekly. The pipeline: ',
+ ),
+ learnMore: s__(
+ 'PipelineSchedules|Learn more in the %{linkStart}scheduled pipelines documentation.%{linkEnd}',
+ ),
+ listElements: [
+ s__('PipelineSchedules|Runs for a specific branch or tag.'),
+ s__('PipelineSchedules|Can have custom CI/CD variables.'),
+ s__('PipelineSchedules|Runs with the same project permissions as the schedule owner.'),
+ ],
+ createNew: s__('PipelineSchedules|Create a new pipeline schedule'),
+ },
+ components: {
+ GlEmptyState,
+ GlLink,
+ GlSprintf,
+ },
+ computed: {
+ scheduleSvgPath() {
+ return `data:image/svg+xml;utf8,${encodeURIComponent(scheduleSvg)}`;
+ },
+ schedulesHelpPath() {
+ return helpPagePath('ci/pipelines/schedules');
+ },
+ },
+};
+</script>
+<template>
+ <gl-empty-state
+ :svg-path="scheduleSvgPath"
+ :primary-button-text="$options.i18n.createNew"
+ primary-button-link="#"
+ >
+ <template #title>
+ <h3>
+ {{ $options.i18n.pipelineSchedules }}
+ </h3>
+ </template>
+ <template #description>
+ <p class="gl-mb-0">{{ $options.i18n.description }}</p>
+ <ul class="gl-list-style-position-inside" data-testid="pipeline-schedules-characteristics">
+ <li v-for="(el, index) in $options.i18n.listElements" :key="index">{{ el }}</li>
+ </ul>
+ <p>
+ <gl-sprintf :message="$options.i18n.learnMore">
+ <template #link="{ content }">
+ <gl-link :href="schedulesHelpPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue
index a4ef7827f73..367b1812a27 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue
@@ -71,7 +71,7 @@ export default {
timezone: this.cronTimezone,
formCiVariables: {},
// TODO: Add the GraphQL query to help populate the predefined variables
- // app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue#131
+ // app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue#131
predefinedValueOptions: {},
};
},
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue
index 8656e5d3536..45b4f618e17 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue
@@ -44,7 +44,14 @@ export default {
<template>
<div class="gl-display-flex gl-justify-content-end">
<gl-button-group>
- <gl-button v-if="canPlay" v-gl-tooltip :title="$options.i18n.playTooltip" icon="play" />
+ <gl-button
+ v-if="canPlay"
+ v-gl-tooltip
+ :title="$options.i18n.playTooltip"
+ icon="play"
+ data-testid="play-pipeline-schedule-btn"
+ @click="$emit('playPipelineSchedule', schedule.id)"
+ />
<gl-button
v-if="canTakeOwnership"
v-gl-tooltip
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue
index 216796b357c..56461165588 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue
@@ -1,9 +1,9 @@
<script>
-import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
+import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
export default {
components: {
- CiBadge,
+ CiBadgeLink,
},
props: {
schedule: {
@@ -24,7 +24,11 @@ export default {
<template>
<div>
- <ci-badge v-if="hasPipeline" :status="lastPipelineStatus" class="gl-vertical-align-middle" />
+ <ci-badge-link
+ v-if="hasPipeline"
+ :status="lastPipelineStatus"
+ class="gl-vertical-align-middle"
+ />
<span v-else data-testid="pipeline-schedule-status-text">
{{ s__('PipelineSchedules|None') }}
</span>
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue
index 1b97a35a51e..e8cfc5b29f3 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue
@@ -96,6 +96,7 @@ export default {
:schedule="item"
@showTakeOwnershipModal="$emit('showTakeOwnershipModal', $event)"
@showDeleteModal="$emit('showDeleteModal', $event)"
+ @playPipelineSchedule="$emit('playPipelineSchedule', $event)"
/>
</template>
</gl-table-lite>
diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/play_pipeline_schedule.mutation.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/play_pipeline_schedule.mutation.graphql
new file mode 100644
index 00000000000..4892f41b93f
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/play_pipeline_schedule.mutation.graphql
@@ -0,0 +1,6 @@
+mutation playPipelineSchedule($id: CiPipelineScheduleID!) {
+ pipelineSchedulePlay(input: { id: $id }) {
+ clientMutationId
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js
index 4c06fa321e5..8bca4f85e9f 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js
+++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js
@@ -18,7 +18,7 @@ export default () => {
return false;
}
- const { fullPath } = containerEl.dataset;
+ const { fullPath, pipelinesPath } = containerEl.dataset;
return new Vue({
el: containerEl,
@@ -26,6 +26,7 @@ export default () => {
apolloProvider,
provide: {
fullPath,
+ pipelinesPath,
},
render(createElement) {
return createElement(PipelineSchedules);
diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue
index efa7909c913..e359344ab77 100644
--- a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue
@@ -3,7 +3,7 @@ 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 CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import RunnerTags from '~/ci/runner/components/runner_tags.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { tableField } from '../utils';
@@ -11,7 +11,7 @@ import LinkCell from './cells/link_cell.vue';
export default {
components: {
- CiBadge,
+ CiBadgeLink,
GlTableLite,
LinkCell,
RunnerTags,
@@ -80,7 +80,7 @@ export default {
fixed
>
<template #cell(status)="{ item = {} }">
- <ci-badge v-if="item.detailedStatus" :status="item.detailedStatus" />
+ <ci-badge-link v-if="item.detailedStatus" :status="item.detailedStatus" />
</template>
<template #cell(job)="{ item = {} }">
diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue
index 2e50dc13d2d..e0a6f4b1e67 100644
--- a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue
+++ b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue
@@ -23,6 +23,8 @@ export default {
RunnerSingleStat,
RunnerUpgradeStatusStats: () =>
import('ee_component/ci/runner/components/stat/runner_upgrade_status_stats.vue'),
+ RunnerPerformanceStat: () =>
+ import('ee_component/ci/runner/components/stat/runner_performance_stat.vue'),
},
props: {
scope: {
@@ -95,6 +97,8 @@ export default {
:scope="scope"
:variables="variables"
/>
+
+ <runner-performance-stat class="gl-px-5" />
</div>
</runner-count>
</template>
diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql
index edfc22f644b..075dbb06190 100644
--- a/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql
@@ -8,7 +8,7 @@ query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String,
nodes {
id
detailedStatus {
- # fields for `<ci-badge>`
+ # fields for `<ci-badge-link>`
id
detailsPath
group
diff --git a/app/assets/javascripts/ci/runner/project_runners/index.js b/app/assets/javascripts/ci/runner/project_runners/index.js
new file mode 100644
index 00000000000..3be2b4a7422
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/project_runners/index.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import ProjectRunnersApp from './project_runners_app.vue';
+
+export const initProjectRunners = (selector = '#js-project-runners') => {
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const { projectFullPath } = el.dataset;
+
+ return new Vue({
+ el,
+ render(h) {
+ return h(ProjectRunnersApp, {
+ props: {
+ projectFullPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci/runner/project_runners/project_runners_app.vue b/app/assets/javascripts/ci/runner/project_runners/project_runners_app.vue
new file mode 100644
index 00000000000..c7bf5e521a1
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/project_runners/project_runners_app.vue
@@ -0,0 +1,19 @@
+<script>
+export default {
+ props: {
+ projectFullPath: {
+ required: true,
+ type: String,
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <!--
+ Under development
+ Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/33803
+ Feature rollout: https://gitlab.com/gitlab-org/gitlab/-/issues/386573
+ -->
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/index.js b/app/assets/javascripts/ci_settings_pipeline_triggers/index.js
index f2972133aad..3ea8e0df022 100644
--- a/app/assets/javascripts/ci_settings_pipeline_triggers/index.js
+++ b/app/assets/javascripts/ci_settings_pipeline_triggers/index.js
@@ -13,11 +13,6 @@ const parseJsonArray = (triggers) => {
export default (containerId = 'js-ci-pipeline-triggers-list') => {
const containerEl = document.getElementById(containerId);
- // Note: Remove this check when FF `ci_pipeline_triggers_settings_vue_ui` is removed.
- if (!containerEl) {
- return null;
- }
-
const triggers = parseJsonArray(containerEl.dataset.triggers);
return new Vue({
diff --git a/app/assets/javascripts/constants.js b/app/assets/javascripts/constants.js
index c56d45166a0..defc2cbe276 100644
--- a/app/assets/javascripts/constants.js
+++ b/app/assets/javascripts/constants.js
@@ -1,3 +1,6 @@
-import { s__ } from '~/locale';
+/* eslint-disable @gitlab/require-i18n-strings */
-export const MODIFIER_KEY = window.gl?.client?.isMac ? '⌘' : s__('KeyboardKey|Ctrl+');
+export const getModifierKey = (removeSuffix = false) => {
+ const winKey = `Ctrl${removeSuffix ? '' : '+'}`;
+ return window.gl?.client?.isMac ? '⌘' : winKey;
+};
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 53a37fc0c51..237808983ee 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -168,7 +168,12 @@ export default {
class="md-area"
:class="{ 'is-focused': focused }"
>
- <formatting-toolbar v-if="!useBottomToolbar" ref="toolbar" class="gl-border-b" />
+ <formatting-toolbar
+ v-if="!useBottomToolbar"
+ ref="toolbar"
+ class="gl-border-b"
+ @enableMarkdownEditor="$emit('enableMarkdownEditor')"
+ />
<div class="gl-relative gl-mt-4">
<formatting-bubble-menu />
<code-block-bubble-menu />
@@ -181,7 +186,12 @@ export default {
/>
<loading-indicator v-if="isLoading" />
</div>
- <formatting-toolbar v-if="useBottomToolbar" ref="toolbar" class="gl-border-t" />
+ <formatting-toolbar
+ v-if="useBottomToolbar"
+ ref="toolbar"
+ class="gl-border-t"
+ @enableMarkdownEditor="$emit('enableMarkdownEditor')"
+ />
</div>
</div>
</content-editor-provider>
diff --git a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
index 8a25ad3fd96..36ca3b8cfb6 100644
--- a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
@@ -1,4 +1,5 @@
<script>
+import EditorModeDropdown from '~/vue_shared/components/markdown/editor_mode_dropdown.vue';
import trackUIControl from '../services/track_ui_control';
import ToolbarButton from './toolbar_button.vue';
import ToolbarImageButton from './toolbar_image_button.vue';
@@ -9,6 +10,7 @@ import ToolbarMoreDropdown from './toolbar_more_dropdown.vue';
export default {
components: {
+ EditorModeDropdown,
ToolbarButton,
ToolbarTextStyleDropdown,
ToolbarLinkButton,
@@ -20,6 +22,11 @@ export default {
trackToolbarControlExecution({ contentType, value }) {
trackUIControl({ property: contentType, value });
},
+ handleEditorModeChanged(mode) {
+ if (mode === 'markdown') {
+ this.$emit('enableMarkdownEditor');
+ }
+ },
},
};
</script>
@@ -101,6 +108,8 @@ export default {
/>
<toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" />
<toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" />
+
+ <editor-mode-dropdown class="gl-ml-auto" value="richText" @input="handleEditorModeChanged" />
</div>
</template>
<style>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
index 2bf32a70cd1..9c1d1faca48 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
@@ -1,13 +1,12 @@
<script>
-import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import { GlTooltipDirective as GlTooltip, GlCollapsibleListbox } from '@gitlab/ui';
import { __ } from '~/locale';
import { TEXT_STYLE_DROPDOWN_ITEMS } from '../constants';
import EditorStateObserver from './editor_state_observer.vue';
export default {
components: {
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
EditorStateObserver,
},
directives: {
@@ -25,15 +24,26 @@ export default {
return activeItem ? activeItem.label : this.$options.i18n.placeholder;
},
+ listboxItems() {
+ return this.$options.items.map((item) => {
+ return {
+ value: item.label,
+ text: item.label,
+ };
+ });
+ },
},
methods: {
+ mapDropdownItemToCommand(dropdownItem) {
+ return this.$options.items.find((option) => option.label === dropdownItem);
+ },
updateActiveItem({ editor }) {
this.activeItem = TEXT_STYLE_DROPDOWN_ITEMS.find((item) =>
editor.isActive(item.contentType, item.commandParams),
);
},
execute(item) {
- const { editorCommand, contentType, commandParams } = item;
+ const { editorCommand, contentType, commandParams } = this.mapDropdownItemToCommand(item);
const value = commandParams?.level;
if (editorCommand) {
@@ -46,8 +56,8 @@ export default {
this.$emit('execute', { contentType, value });
},
- isActive(item) {
- return this.tiptapEditor.isActive(item.contentType, item.commandParams);
+ isActive(dropdownItem) {
+ return this.tiptapEditor.isActive(dropdownItem.contentType, dropdownItem.commandParams);
},
},
items: TEXT_STYLE_DROPDOWN_ITEMS,
@@ -58,25 +68,15 @@ export default {
</script>
<template>
<editor-state-observer @transaction="updateActiveItem">
- <gl-dropdown
- v-gl-tooltip="$options.i18n.placeholder"
- size="small"
- data-qa-selector="text_style_dropdown"
+ <gl-collapsible-listbox
+ v-gl-tooltip.hover="$options.i18n.placeholder"
+ :items="listboxItems"
+ :toggle-text="activeItemLabel"
+ :selected="activeItemLabel"
:disabled="!activeItem"
- :text="activeItemLabel"
- lazy
- >
- <gl-dropdown-item
- v-for="(item, index) in $options.items"
- :key="index"
- is-check-item
- :is-checked="isActive(item)"
- data-qa-selector="text_style_menu_item"
- :data-qa-text-style="item.label"
- @click="execute(item)"
- >
- {{ item.label }}
- </gl-dropdown-item>
- </gl-dropdown>
+ :data-qa-text-style="activeItemLabel"
+ data-qa-selector="text_style_dropdown"
+ @select="execute"
+ />
</editor-state-observer>
</template>
diff --git a/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue b/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue
index c6aeb6c726d..9811a0774e1 100644
--- a/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue
+++ b/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue
@@ -11,7 +11,7 @@ import { debounce } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { backOff } from '~/lib/utils/common_utils';
import csrf from '~/lib/utils/csrf';
-import statusCodes from '~/lib/utils/http_status';
+import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { __, s__ } from '~/locale';
import { queryTypes, formDataValidator } from '../constants';
@@ -23,7 +23,7 @@ function backOffRequest(makeRequestCallback) {
return backOff((next, stop) => {
makeRequestCallback()
.then((resp) => {
- if (resp.status === statusCodes.OK) {
+ if (resp.status === HTTP_STATUS_OK) {
stop(resp);
} else {
next();
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 48ab9ce0a3c..57fae608efa 100644
--- a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue
+++ b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue
@@ -288,7 +288,7 @@ export default {
</div>
</gl-form-group>
<div>
- <gl-button variant="success" @click="createDeployToken">
+ <gl-button variant="confirm" @click="createDeployToken">
{{ $options.translations.addTokenButton }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index c090a66a69d..8019a10a042 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -15,6 +15,7 @@ import Autosize from 'autosize';
import $ from 'jquery';
import { escape, uniqueId } from 'lodash';
import Vue from 'vue';
+import { renderGFM } from '~/behaviors/markdown/render_gfm';
import { createAlert, VARIANT_INFO } from '~/flash';
import '~/lib/utils/jquery_at_who';
import AjaxCache from '~/lib/utils/ajax_cache';
@@ -40,7 +41,6 @@ import { localTimeAgo } from './lib/utils/datetime_utility';
import { getLocationHash } from './lib/utils/url_utility';
import { sprintf, s__, __ } from './locale';
import TaskList from './task_list';
-import '~/behaviors/markdown/init_gfm';
window.autosize = Autosize;
@@ -516,7 +516,11 @@ export default class Notes {
}
if (discussionContainer.length === 0) {
if (noteEntity.diff_discussion_html) {
- const $discussion = $(noteEntity.diff_discussion_html).renderGFM();
+ const discussionElement = document.createElement('table');
+ // eslint-disable-next-line no-unsanitized/method
+ discussionElement.insertAdjacentHTML('afterbegin', noteEntity.diff_discussion_html);
+ renderGFM(discussionElement);
+ const $discussion = $(discussionElement).unwrap();
if (!this.isParallelView() || row.hasClass('js-temp-notes-holder') || noteEntity.on_image) {
// insert the note and the reply button after the temp row
@@ -571,7 +575,9 @@ export default class Notes {
// reset text and preview
form.find('.js-md-write-button').click();
form.find('.js-note-text').val('').trigger('input');
- form.find('.js-note-text').data('autosave').reset();
+ form.find('.js-note-text').each(function reset() {
+ this.$autosave.reset();
+ });
const event = document.createEvent('Event');
event.initEvent('autosize:update', true, false);
@@ -638,7 +644,9 @@ export default class Notes {
// DiffNote
form.find('#note_position').val(),
];
- return new Autosave(textarea, key);
+ const textareaEl = textarea.get(0);
+ // eslint-disable-next-line no-new
+ if (textareaEl) new Autosave(textareaEl, key);
}
/**
@@ -708,7 +716,7 @@ export default class Notes {
$noteAvatar.append($targetNoteBadge);
this.revertNoteEditForm($targetNote);
- $noteEntityEl.renderGFM();
+ renderGFM($noteEntityEl.get(0));
// Find the note's `li` element by ID and replace it with the updated HTML
const $note_li = $(`.note-row-${noteEntity.id}`);
@@ -1082,7 +1090,9 @@ export default class Notes {
const row = form.closest('tr');
const glForm = form.data('glForm');
glForm.destroy();
- form.find('.js-note-text').data('autosave').reset();
+ form.find('.js-note-text').each(function reset() {
+ this.$autosave.reset();
+ });
// show the reply button (will only work for replies)
form.prev('.discussion-reply-holder').show();
if (row.is('.js-temp-notes-holder')) {
@@ -1382,7 +1392,8 @@ export default class Notes {
static animateAppendNote(noteHtml, $notesList) {
const $note = $(noteHtml);
- $note.addClass('fade-in-full').renderGFM();
+ $note.addClass('fade-in-full');
+ renderGFM($note.get(0));
$notesList.append($note);
return $note;
}
@@ -1390,7 +1401,8 @@ export default class Notes {
static animateUpdateNote(noteHtml, $note) {
const $updatedNote = $(noteHtml);
- $updatedNote.addClass('fade-in').renderGFM();
+ $updatedNote.addClass('fade-in');
+ renderGFM($updatedNote.get(0));
$note.replaceWith($updatedNote);
return $updatedNote;
}
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
index 5a6b220e532..830f16b50ee 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
@@ -1,6 +1,5 @@
<script>
import { GlButton } from '@gitlab/ui';
-import $ from 'jquery';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
import Autosave from '~/autosave';
@@ -118,7 +117,7 @@ export default {
},
initAutosaveComment() {
if (this.isLoggedIn) {
- this.autosaveDiscussion = new Autosave($(this.$refs.textarea), [
+ this.autosaveDiscussion = new Autosave(this.$refs.textarea, [
s__('DesignManagement|Discussion'),
getIdFromGraphQLId(this.noteableId),
this.shortDiscussionId,
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index 23eb470503e..65816495432 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -12,9 +12,13 @@ const UNFOLD_COUNT = 20;
let isBound = false;
export default class Diff {
- constructor() {
+ constructor({ mergeRequestEventHub } = {}) {
const $diffFile = $('.files .diff-file');
+ if (mergeRequestEventHub) {
+ this.mrHub = mergeRequestEventHub;
+ }
+
$diffFile.each((index, file) => {
if (!$.data(file, 'singleFileDiff')) {
$.data(file, 'singleFileDiff', new SingleFileDiff(file));
@@ -34,7 +38,8 @@ export default class Diff {
$(document)
.on('click', '.js-unfold', this.handleClickUnfold.bind(this))
.on('click', '.diff-line-num a', this.handleClickLineNum.bind(this))
- .on('mousedown', 'td.line_content.parallel', this.handleParallelLineDown.bind(this));
+ .on('mousedown', 'td.line_content.parallel', this.handleParallelLineDown.bind(this))
+ .on('click', '.inline-parallel-buttons a', ($e) => this.viewTypeSwitch($e));
isBound = true;
}
@@ -135,6 +140,20 @@ export default class Diff {
diffViewType() {
return $('.inline-parallel-buttons a.active').data('viewType');
}
+ viewTypeSwitch(event) {
+ const click = event.originalEvent;
+ const diffSource = new URL(click.target.getAttribute('href'), document.location.href);
+
+ if (this.mrHub) {
+ click.preventDefault();
+ click.stopPropagation();
+
+ diffSource.pathname = `${diffSource.pathname}.json`;
+
+ this.mrHub.$emit('diff:switch-view-type', { source: diffSource.toString() });
+ }
+ }
+
// eslint-disable-next-line class-methods-use-this
lineNumbers(line) {
const children = line.find('.diff-line-num').toArray();
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 7bc75127876..35d1a564178 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -389,10 +389,7 @@ export default {
() => {
this.setDiscussions();
- if (
- this.$store.state.notes.doneFetchingBatchDiscussions &&
- window.gon?.features?.paginatedMrDiscussions
- ) {
+ if (this.$store.state.notes.doneFetchingBatchDiscussions) {
this.unwatchDiscussions();
}
},
@@ -402,10 +399,6 @@ export default {
() => `${this.retrievingBatches}:${this.$store.state.notes.discussions.length}`,
() => {
if (!this.retrievingBatches && this.$store.state.notes.discussions.length) {
- if (!window.gon?.features?.paginatedMrDiscussions) {
- this.unwatchDiscussions();
- }
-
this.unwatchRetrievingBatches();
}
},
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index dff61acdfba..16f45c3ad6a 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -427,6 +427,7 @@ export default {
:href="diffFile.ide_edit_path"
class="js-ide-edit-blob"
data-qa-selector="edit_in_ide_button"
+ target="_blank"
>
{{ __('Open in Web IDE') }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue b/app/assets/javascripts/diffs/components/merge_conflict_warning.vue
index c37a1d75650..6cb1ed4cbcf 100644
--- a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue
+++ b/app/assets/javascripts/diffs/components/merge_conflict_warning.vue
@@ -36,7 +36,7 @@ export default {
<p class="gl-mb-0">
{{
__(
- 'Resolve these conflicts or ask someone with write access to this repository to merge it locally.',
+ 'Resolve these conflicts, or ask someone with write access to this repository to resolve them locally.',
)
}}
</p>
@@ -54,7 +54,7 @@ export default {
v-gl-modal-directive="'modal-merge-info'"
class="gl-alert-action"
>
- {{ __('Merge locally') }}
+ {{ __('Resolve locally') }}
</gl-button>
</template>
</gl-alert>
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index ffbea854001..abf77fa2ede 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -2,10 +2,13 @@
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import micromatch from 'micromatch';
+import { getModifierKey } from '~/constants';
import { s__, sprintf } from '~/locale';
import FileTree from '~/vue_shared/components/file_tree.vue';
import DiffFileRow from './diff_file_row.vue';
+const MODIFIER_KEY = getModifierKey();
+
export default {
directives: {
GlTooltip: GlTooltipDirective,
@@ -65,8 +68,8 @@ export default {
this.search = '';
},
},
- searchPlaceholder: sprintf(s__('MergeRequest|Search (e.g. *.vue) (%{modifier_key}P)'), {
- modifier_key: /Mac/i.test(navigator.userAgent) ? '⌘' : 'Ctrl+',
+ searchPlaceholder: sprintf(s__('MergeRequest|Search (e.g. *.vue) (%{MODIFIER_KEY}P)'), {
+ MODIFIER_KEY,
}),
DiffFileRow,
};
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 96a73917820..9f90de9abde 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -9,7 +9,7 @@ import { createAlert, VARIANT_WARNING } from '~/flash';
import { diffViewerModes } from '~/ide/constants';
import axios from '~/lib/utils/axios_utils';
-import httpStatusCodes from '~/lib/utils/http_status';
+import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import Poll from '~/lib/utils/poll';
import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
@@ -232,7 +232,7 @@ export const fetchDiffFilesMeta = ({ commit, state }) => {
.catch((error) => {
worker.terminate();
- if (error.response.status === httpStatusCodes.NOT_FOUND) {
+ if (error.response.status === HTTP_STATUS_NOT_FOUND) {
createAlert({
message: __('Building your merge request. Wait a few moments, then refresh this page.'),
variant: VARIANT_WARNING,
@@ -248,7 +248,7 @@ export const fetchCoverageFiles = ({ commit, state }) => {
data: state.endpointCoverage,
method: 'getCoverageReports',
successCallback: ({ status, data }) => {
- if (status === httpStatusCodes.OK) {
+ if (status === HTTP_STATUS_OK) {
commit(types.SET_COVERAGE_DATA, data);
coveragePoll.stop();
diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js
index d0649ecccba..d235319dfd7 100644
--- a/app/assets/javascripts/editor/constants.js
+++ b/app/assets/javascripts/editor/constants.js
@@ -1,7 +1,9 @@
-import { MODIFIER_KEY } from '~/constants';
+import { getModifierKey } from '~/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { s__, __, sprintf } from '~/locale';
+const modifierKey = getModifierKey();
+
export const URI_PREFIX = 'gitlab';
export const CONTENT_UPDATE_DEBOUNCE = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
@@ -67,7 +69,7 @@ export const EXTENSION_MARKDOWN_BUTTONS = [
{
id: 'bold',
label: sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), {
- modifierKey: MODIFIER_KEY,
+ modifierKey,
}),
data: {
mdTag: '**',
@@ -77,7 +79,7 @@ export const EXTENSION_MARKDOWN_BUTTONS = [
{
id: 'italic',
label: sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), {
- modifierKey: MODIFIER_KEY,
+ modifierKey,
}),
data: {
mdTag: '_',
@@ -87,7 +89,7 @@ export const EXTENSION_MARKDOWN_BUTTONS = [
{
id: 'strikethrough',
label: sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}⇧X)'), {
- modifierKey: MODIFIER_KEY,
+ modifierKey,
}),
data: {
mdTag: '~~',
@@ -113,7 +115,7 @@ export const EXTENSION_MARKDOWN_BUTTONS = [
{
id: 'link',
label: sprintf(s__('MarkdownEditor|Add a link (%{modifier_key}K)'), {
- modifierKey: MODIFIER_KEY,
+ modifierKey,
}),
data: {
mdTag: '[{text}](url)',
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index d94aa73e43a..87d869cc996 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -239,8 +239,15 @@
}
]
},
+ "browser_performance": {
+ "type": "string",
+ "description": "Path to a single file with browser performance metric report(s)."
+ },
"coverage_report": {
- "type": "object",
+ "type": [
+ "object",
+ "null"
+ ],
"description": "Used to collect coverage reports from the job.",
"properties": {
"coverage_format": {
@@ -292,10 +299,6 @@
"$ref": "#/definitions/string_file_list",
"description": "Path to file or list of files with license report(s)."
},
- "performance": {
- "$ref": "#/definitions/string_file_list",
- "description": "Path to file or list of files with performance metrics report(s)."
- },
"requirements": {
"$ref": "#/definitions/string_file_list",
"description": "Path to file or list of files with requirements report(s)."
@@ -703,7 +706,10 @@
}
},
"rules": {
- "type": "array",
+ "type": [
+ "array",
+ "null"
+ ],
"markdownDescription": "Rules allows for an array of individual rule objects to be evaluated in order, until one matches and dynamically provides attributes to the job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#rules).",
"items": {
"anyOf": [
@@ -994,6 +1000,11 @@
"pull-push"
]
},
+ "unprotect": {
+ "type": "boolean",
+ "markdownDescription": "Use `unprotect: true` to set a cache to be shared between protected and unprotected branches.",
+ "default": false
+ },
"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)",
@@ -1601,7 +1612,7 @@
"description": "Creates N instances of the same job that run in parallel.",
"default": 0,
"minimum": 2,
- "maximum": 50
+ "maximum": 200
},
{
"type": "object",
@@ -1620,7 +1631,7 @@
]
}
},
- "maxItems": 50
+ "maxItems": 200
}
},
"additionalProperties": false,
diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_job.vue b/app/assets/javascripts/environments/environment_details/components/deployment_job.vue
new file mode 100644
index 00000000000..dbe25a81550
--- /dev/null
+++ b/app/assets/javascripts/environments/environment_details/components/deployment_job.vue
@@ -0,0 +1,24 @@
+<script>
+import { GlTruncate, GlLink, GlBadge } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlBadge,
+ GlTruncate,
+ GlLink,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+};
+</script>
+<template>
+ <gl-link v-if="job" :href="job.webPath">
+ <gl-truncate :text="job.label" />
+ </gl-link>
+ <gl-badge v-else variant="info">{{ __('API') }}</gl-badge>
+</template>
diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_status_link.vue b/app/assets/javascripts/environments/environment_details/components/deployment_status_link.vue
new file mode 100644
index 00000000000..82926e2e596
--- /dev/null
+++ b/app/assets/javascripts/environments/environment_details/components/deployment_status_link.vue
@@ -0,0 +1,26 @@
+<script>
+import DeploymentStatusBadge from '~/environments/components/deployment_status_badge.vue';
+
+export default {
+ components: {
+ DeploymentStatusBadge,
+ },
+ props: {
+ status: {
+ type: String,
+ required: true,
+ },
+ deploymentJob: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+};
+</script>
+<template>
+ <a v-if="deploymentJob" data-testid="deployment-status-job-link" :href="deploymentJob.webPath">
+ <deployment-status-badge :status="status" />
+ </a>
+ <deployment-status-badge v-else :status="status" />
+</template>
diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_triggerer.vue b/app/assets/javascripts/environments/environment_details/components/deployment_triggerer.vue
new file mode 100644
index 00000000000..18ff31f9b0f
--- /dev/null
+++ b/app/assets/javascripts/environments/environment_details/components/deployment_triggerer.vue
@@ -0,0 +1,25 @@
+<script>
+import { GlAvatar, GlAvatarLink, GlTooltipDirective } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlAvatar,
+ GlAvatarLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ triggerer: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+};
+</script>
+<template>
+ <gl-avatar-link v-if="triggerer" :href="triggerer.webUrl">
+ <gl-avatar v-gl-tooltip :title="triggerer.name" :src="triggerer.avatarUrl" :size="24" />
+ </gl-avatar-link>
+</template>
diff --git a/app/assets/javascripts/environments/environment_details/constants.js b/app/assets/javascripts/environments/environment_details/constants.js
index 56c70c354b7..bf690ffedeb 100644
--- a/app/assets/javascripts/environments/environment_details/constants.js
+++ b/app/assets/javascripts/environments/environment_details/constants.js
@@ -1,4 +1,5 @@
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
export const ENVIRONMENT_DETAILS_PAGE_SIZE = 20;
export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [
@@ -45,3 +46,17 @@ export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [
tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap',
},
];
+
+export const translations = {
+ emptyStateTitle: s__("Deployments|You don't have any deployments right now."),
+ emptyStatePrimaryButton: __('Read more'),
+ emptyStateDescription: s__(
+ 'Deployments|Define environments in the deploy stage(s) in %{code_open}.gitlab-ci.yml%{code_close} to track deployments here.',
+ ),
+ nextPageButtonLabel: __('Next'),
+ previousPageButtonLabel: __('Prev'),
+};
+
+export const codeBlockPlaceholders = { code: ['code_open', 'code_close'] };
+
+export const environmentsHelpPagePath = helpPagePath('ci/environments/index.md');
diff --git a/app/assets/javascripts/environments/environment_details/deployments_table.vue b/app/assets/javascripts/environments/environment_details/deployments_table.vue
new file mode 100644
index 00000000000..41570ee44c0
--- /dev/null
+++ b/app/assets/javascripts/environments/environment_details/deployments_table.vue
@@ -0,0 +1,55 @@
+<script>
+import { GlTableLite } from '@gitlab/ui';
+import Commit from '~/vue_shared/components/commit.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import DeploymentStatusLink from './components/deployment_status_link.vue';
+import DeploymentJob from './components/deployment_job.vue';
+import DeploymentTriggerer from './components/deployment_triggerer.vue';
+import { ENVIRONMENT_DETAILS_TABLE_FIELDS } from './constants';
+
+export default {
+ components: {
+ DeploymentTriggerer,
+ DeploymentJob,
+ Commit,
+ TimeAgoTooltip,
+ DeploymentStatusLink,
+ GlTableLite,
+ },
+ props: {
+ deployments: {
+ type: Array,
+ required: true,
+ },
+ },
+ tableFields: ENVIRONMENT_DETAILS_TABLE_FIELDS,
+};
+</script>
+<template>
+ <gl-table-lite :items="deployments" :fields="$options.tableFields" fixed stacked="lg">
+ <template #table-colgroup="{ fields }">
+ <col v-for="field in fields" :key="field.key" :class="field.columnClass" />
+ </template>
+ <template #cell(status)="{ item }">
+ <deployment-status-link :deployment-job="item.job" :status="item.status" />
+ </template>
+ <template #cell(id)="{ item }">
+ <strong>{{ item.id }}</strong>
+ </template>
+ <template #cell(triggerer)="{ item }">
+ <deployment-triggerer :triggerer="item.triggerer" />
+ </template>
+ <template #cell(commit)="{ item }">
+ <commit v-bind="item.commit" />
+ </template>
+ <template #cell(job)="{ item }">
+ <deployment-job :job="item.job" />
+ </template>
+ <template #cell(created)="{ item }">
+ <time-ago-tooltip :time="item.created" />
+ </template>
+ <template #cell(deployed)="{ item }">
+ <time-ago-tooltip :time="item.deployed" />
+ </template>
+ </gl-table-lite>
+</template>
diff --git a/app/assets/javascripts/environments/environment_details/empty_state.vue b/app/assets/javascripts/environments/environment_details/empty_state.vue
new file mode 100644
index 00000000000..6f08b319408
--- /dev/null
+++ b/app/assets/javascripts/environments/environment_details/empty_state.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlEmptyState, GlSprintf } from '@gitlab/ui';
+import { translations, codeBlockPlaceholders, environmentsHelpPagePath } from './constants';
+
+export default {
+ components: {
+ GlSprintf,
+ GlEmptyState,
+ },
+ translations,
+ actionButtonUrl: environmentsHelpPagePath,
+ placeholders: {
+ code: codeBlockPlaceholders,
+ },
+};
+</script>
+<template>
+ <gl-empty-state
+ :title="$options.translations.emptyStateTitle"
+ :primary-button-text="$options.translations.emptyStatePrimaryButton"
+ :primary-button-link="$options.actionButtonUrl"
+ >
+ <template #description>
+ <gl-sprintf
+ :message="$options.translations.emptyStateDescription"
+ :placeholders="$options.placeholders.code"
+ >
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue
index 435d3fd820e..b43f4233b9c 100644
--- a/app/assets/javascripts/environments/environment_details/index.vue
+++ b/app/assets/javascripts/environments/environment_details/index.vue
@@ -1,36 +1,19 @@
<script>
-import {
- GlTableLite,
- GlAvatarLink,
- GlAvatar,
- GlLink,
- GlTooltipDirective,
- GlTruncate,
- GlBadge,
- GlLoadingIcon,
-} from '@gitlab/ui';
-import Commit from '~/vue_shared/components/commit.vue';
-import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { logError } from '~/lib/logger';
import environmentDetailsQuery from '../graphql/queries/environment_details.query.graphql';
import { convertToDeploymentTableRow } from '../helpers/deployment_data_transformation_helper';
-import DeploymentStatusBadge from '../components/deployment_status_badge.vue';
-import { ENVIRONMENT_DETAILS_PAGE_SIZE, ENVIRONMENT_DETAILS_TABLE_FIELDS } from './constants';
+import EmptyState from './empty_state.vue';
+import DeploymentsTable from './deployments_table.vue';
+import Pagination from './pagination.vue';
+import { ENVIRONMENT_DETAILS_PAGE_SIZE } from './constants';
export default {
components: {
+ Pagination,
+ DeploymentsTable,
+ EmptyState,
GlLoadingIcon,
- GlBadge,
- DeploymentStatusBadge,
- TimeAgoTooltip,
- GlTableLite,
- GlAvatarLink,
- GlAvatar,
- GlLink,
- GlTruncate,
- Commit,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
},
props: {
projectFullPath: {
@@ -41,6 +24,16 @@ export default {
type: String,
required: true,
},
+ after: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ before: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
apollo: {
project: {
@@ -49,18 +42,19 @@ export default {
return {
projectFullPath: this.projectFullPath,
environmentName: this.environmentName,
- pageSize: ENVIRONMENT_DETAILS_PAGE_SIZE,
+ first: this.before ? null : ENVIRONMENT_DETAILS_PAGE_SIZE,
+ last: this.before ? ENVIRONMENT_DETAILS_PAGE_SIZE : null,
+ after: this.after,
+ before: this.before,
};
},
},
},
data() {
return {
- project: {
- loading: true,
- },
- loading: 0,
- tableFields: ENVIRONMENT_DETAILS_TABLE_FIELDS,
+ project: {},
+ isInitialPageDataReceived: false,
+ isPrefetchingPages: false,
};
},
computed: {
@@ -70,49 +64,80 @@ export default {
isLoading() {
return this.$apollo.queries.project.loading;
},
+ isDeploymentTableShown() {
+ return this.isInitialPageDataReceived === true && this.deployments.length > 0;
+ },
+ pageInfo() {
+ return this.project.environment?.deployments.pageInfo || {};
+ },
+ isPaginationDisabled() {
+ return this.isLoading || this.isPrefetchingPages;
+ },
+ },
+ watch: {
+ async project(newProject) {
+ this.isInitialPageDataReceived = true;
+ this.isPrefetchingPages = true;
+
+ try {
+ // TLDR: when we load a page, if there's next and/or previous pages existing, we'll load their data as well to improve percepted performance.
+ const {
+ endCursor,
+ hasPreviousPage,
+ hasNextPage,
+ startCursor,
+ } = newProject.environment.deployments.pageInfo;
+
+ // At the moment we have a limit of deployments being requested only from a signle environment entity per query,
+ // and apparently two batched queries count as one on server-side
+ // to load both next and previous page data, we have to query them sequentially
+ if (hasNextPage) {
+ await this.$apollo.query({
+ query: environmentDetailsQuery,
+ variables: {
+ projectFullPath: this.projectFullPath,
+ environmentName: this.environmentName,
+ first: ENVIRONMENT_DETAILS_PAGE_SIZE,
+ after: endCursor,
+ before: null,
+ last: null,
+ },
+ });
+ }
+
+ if (hasPreviousPage) {
+ await this.$apollo.query({
+ query: environmentDetailsQuery,
+ variables: {
+ projectFullPath: this.projectFullPath,
+ environmentName: this.environmentName,
+ first: null,
+ after: null,
+ before: startCursor,
+ last: ENVIRONMENT_DETAILS_PAGE_SIZE,
+ },
+ });
+ }
+ } catch (error) {
+ logError(error);
+ }
+
+ this.isPrefetchingPages = false;
+ },
},
};
</script>
<template>
- <div>
- <gl-loading-icon v-if="isLoading" size="lg" class="mt-3" />
- <gl-table-lite v-else :items="deployments" :fields="tableFields" fixed stacked="lg">
- <template #table-colgroup="{ fields }">
- <col v-for="field in fields" :key="field.key" :class="field.columnClass" />
- </template>
- <template #cell(status)="{ item }">
- <div>
- <deployment-status-badge :status="item.status" />
- </div>
- </template>
- <template #cell(id)="{ item }">
- <strong>{{ item.id }}</strong>
- </template>
- <template #cell(triggerer)="{ item }">
- <gl-avatar-link :href="item.triggerer.webUrl">
- <gl-avatar
- v-gl-tooltip
- :title="item.triggerer.name"
- :src="item.triggerer.avatarUrl"
- :size="24"
- />
- </gl-avatar-link>
- </template>
- <template #cell(commit)="{ item }">
- <commit v-bind="item.commit" />
- </template>
- <template #cell(job)="{ item }">
- <gl-link v-if="item.job" :href="item.job.webPath">
- <gl-truncate :text="item.job.label" />
- </gl-link>
- <gl-badge v-else variant="info">{{ __('API') }}</gl-badge>
- </template>
- <template #cell(created)="{ item }">
- <time-ago-tooltip :time="item.created" />
- </template>
- <template #cell(deployed)="{ item }">
- <time-ago-tooltip :time="item.deployed" />
- </template>
- </gl-table-lite>
+ <div class="gl-relative gl-min-h-6">
+ <div
+ v-if="isLoading"
+ class="gl-absolute gl-top-0 gl-left-0 gl-w-full gl-h-full gl-z-index-200 gl-bg-gray-10 gl-opacity-3"
+ ></div>
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-absolute gl-top-half gl-left-50p" />
+ <div v-if="isDeploymentTableShown">
+ <deployments-table :deployments="deployments" />
+ <pagination :page-info="pageInfo" :disabled="isPaginationDisabled" />
+ </div>
+ <empty-state v-if="!isDeploymentTableShown && !isLoading" />
</div>
</template>
diff --git a/app/assets/javascripts/environments/environment_details/pagination.vue b/app/assets/javascripts/environments/environment_details/pagination.vue
new file mode 100644
index 00000000000..414610b306a
--- /dev/null
+++ b/app/assets/javascripts/environments/environment_details/pagination.vue
@@ -0,0 +1,74 @@
+<script>
+import { GlKeysetPagination } from '@gitlab/ui';
+import { setUrlParams } from '~/lib/utils/url_utility';
+import { translations } from './constants';
+
+export default {
+ components: {
+ GlKeysetPagination,
+ },
+ props: {
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ translations,
+ computed: {
+ previousLink() {
+ if (!this.pageInfo || !this.pageInfo.hasPreviousPage) {
+ return '';
+ }
+ return setUrlParams({ before: this.pageInfo.startCursor }, window.location.href, true);
+ },
+ nextLink() {
+ if (!this.pageInfo || !this.pageInfo.hasNextPage) {
+ return '';
+ }
+ return setUrlParams({ after: this.pageInfo.endCursor }, window.location.href, true);
+ },
+ isPaginationVisible() {
+ if (!this.pageInfo) {
+ return false;
+ }
+
+ return this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage;
+ },
+ },
+ methods: {
+ onPrev(previousCursor) {
+ this.$router.push({ query: { before: previousCursor } });
+ },
+ onNext(nextCursor) {
+ this.$router.push({ query: { after: nextCursor } });
+ },
+ onPaginationClick(event) {
+ // this check here is to ensure the proper default behvaior when a user ctrl/cmd + clicks the link
+ if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) {
+ return;
+ }
+ event.preventDefault();
+ },
+ },
+};
+</script>
+<template>
+ <div v-if="isPaginationVisible" class="gl--flex-center">
+ <gl-keyset-pagination
+ v-bind="pageInfo"
+ :prev-text="$options.translations.previousPageButtonLabel"
+ :next-text="$options.translations.nextPageButtonLabel"
+ :prev-button-link="previousLink"
+ :next-button-link="nextLink"
+ :disabled="disabled"
+ @prev="onPrev"
+ @next="onNext"
+ @click="onPaginationClick"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql
index e8f2a2cdf7f..c6c2024c840 100644
--- a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql
@@ -1,4 +1,11 @@
-query getEnvironmentDetails($projectFullPath: ID!, $environmentName: String, $pageSize: Int) {
+query getEnvironmentDetails(
+ $projectFullPath: ID!
+ $environmentName: String
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
+) {
project(fullPath: $projectFullPath) {
id
name
@@ -6,7 +13,19 @@ query getEnvironmentDetails($projectFullPath: ID!, $environmentName: String, $pa
environment(name: $environmentName) {
id
name
- deployments(orderBy: { createdAt: DESC }, first: $pageSize) {
+ deployments(
+ orderBy: { createdAt: DESC }
+ first: $first
+ last: $last
+ after: $after
+ before: $before
+ ) {
+ pageInfo {
+ startCursor
+ endCursor
+ hasNextPage
+ hasPreviousPage
+ }
nodes {
id
iid
diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js
index ba816599ac2..afce2b7f237 100644
--- a/app/assets/javascripts/environments/mount_show.js
+++ b/app/assets/javascripts/environments/mount_show.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import VueRouter from 'vue-router';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import EnvironmentsDetailHeader from './components/environments_detail_header.vue';
import { apolloProvider } from './graphql/client';
@@ -43,7 +44,7 @@ export const initHeader = () => {
cancelAutoStopPath: dataset.environmentCancelAutoStopPath,
terminalPath: dataset.environmentTerminalPath,
metricsPath: dataset.environmentMetricsPath,
- updatePath: dataset.tnvironmentEditPath,
+ updatePath: dataset.environmentEditPath,
},
});
},
@@ -60,18 +61,40 @@ export const initPage = async () => {
const dataSet = convertObjectPropsToCamelCase(JSON.parse(dataElement.dataset.details));
Vue.use(VueApollo);
+ Vue.use(VueRouter);
const el = document.getElementById('environment_details_page');
+
+ const router = new VueRouter({
+ mode: 'history',
+ base: window.location.pathname,
+ routes: [
+ {
+ path: '/',
+ name: 'environment_details',
+ component: EnvironmentsDetailPage,
+ props: (route) => ({
+ after: route.query.after,
+ before: route.query.before,
+ projectFullPath: dataSet.projectFullPath,
+ environmentName: dataSet.name,
+ }),
+ },
+ ],
+ scrollBehavior(to, from, savedPosition) {
+ if (savedPosition) {
+ return savedPosition;
+ }
+ return { top: 0 };
+ },
+ });
+
return new Vue({
el,
apolloProvider: apolloProvider(),
+ router,
provide: {},
render(createElement) {
- return createElement(EnvironmentsDetailPage, {
- props: {
- projectFullPath: dataSet.projectFullPath,
- environmentName: dataSet.name,
- },
- });
+ return createElement('router-view');
},
});
};
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
index 122c7c005e9..2a4bb88b6c2 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -161,7 +161,7 @@ export default {
return this.pagination.next ? this.$options.NEXT_PAGE : null;
},
errorTrackingHelpUrl() {
- return helpPagePath('operations/error_tracking');
+ return helpPagePath('operations/error_tracking.html#integrated-error-tracking');
},
showIntegratedDisabledAlert() {
return !this.isAlertDismissed && this.showIntegratedTrackingDisabledAlert;
@@ -175,6 +175,7 @@ export default {
},
},
epicLink: 'https://gitlab.com/gitlab-org/gitlab/-/issues/353639',
+ openBetaLink: 'https://about.gitlab.com/handbook/product/gitlab-the-product/#open-beta',
featureFlagLink: helpPagePath('operations/error_tracking'),
created() {
if (this.errorTrackingEnabled) {
@@ -454,24 +455,19 @@ export default {
/>
</template>
</div>
- <div v-else-if="userCanEnableErrorTracking">
- <gl-empty-state
- :title="__('Get started with error tracking')"
- :description="__('Monitor your errors by integrating with Sentry.')"
- :primary-button-text="__('Enable error tracking')"
- :primary-button-link="enableErrorTrackingLink"
- :svg-path="illustrationPath"
- />
- </div>
<div v-else>
<gl-empty-state :title="__('Get started with error tracking')" :svg-path="illustrationPath">
<template #description>
<div>
- <span>{{ __('Monitor your errors by integrating with Sentry.') }}</span>
+ <span>{{ __('Monitor your errors directly in GitLab.') }}</span>
<gl-link target="_blank" :href="errorTrackingHelpUrl">{{
- __('More information')
+ __('How do I get started?')
}}</gl-link>
</div>
+ <div class="gl-mt-3">
+ <span>{{ __('Error tracking is currently in') }}</span>
+ <gl-link target="_blank" :href="$options.openBetaLink">{{ __('Open Beta.') }}</gl-link>
+ </div>
</template>
</gl-empty-state>
</div>
diff --git a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue
index 2323370a3aa..cd101f57d4f 100644
--- a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue
@@ -1,11 +1,10 @@
<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import { getDisplayName } from '../utils';
export default {
components: {
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
},
props: {
dropdownLabel: {
@@ -42,8 +41,21 @@ export default {
required: true,
},
},
+ computed: {
+ listboxItems() {
+ return this.projects.map((project) => {
+ return {
+ text: getDisplayName(project),
+ value: project.id,
+ };
+ });
+ },
+ },
methods: {
- getDisplayName,
+ selectProject(id) {
+ const project = this.projects.find((p) => p.id === id);
+ this.$emit('select-project', project);
+ },
},
};
</script>
@@ -52,22 +64,15 @@ export default {
<div :class="{ 'gl-show-field-errors': isProjectInvalid }">
<label class="label-bold" for="project-dropdown">{{ __('Project') }}</label>
<div class="row">
- <gl-dropdown
+ <gl-collapsible-listbox
id="project-dropdown"
- class="col-8 col-md-9 gl-pr-0"
+ class="gl-pl-5"
:disabled="!hasProjects"
- menu-class="w-100 mw-100"
- toggle-class="dropdown-menu-toggle gl-field-error-outline"
- :text="dropdownLabel"
- >
- <gl-dropdown-item
- v-for="project in projects"
- :key="`${project.organizationSlug}.${project.slug}`"
- class="w-100"
- @click="$emit('select-project', project)"
- >{{ getDisplayName(project) }}</gl-dropdown-item
- >
- </gl-dropdown>
+ :items="listboxItems"
+ :selected="selectedProject && selectedProject.id"
+ :toggle-text="dropdownLabel"
+ @select="selectProject"
+ />
</div>
<p v-if="isProjectInvalid" class="js-project-dropdown-error gl-field-error">
{{ invalidProjectLabel }}
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index 9e804b60d59..cebf73ef8e5 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -1,9 +1,7 @@
import * as Sentry from '@sentry/browser';
-import { escape } from 'lodash';
import Vue from 'vue';
import { GlAlert } from '@gitlab/ui';
import { __ } from '~/locale';
-import { spriteIcon } from './lib/utils/common_utils';
const FLASH_TYPES = {
ALERT: 'alert',
@@ -18,13 +16,6 @@ const VARIANT_DANGER = 'danger';
const VARIANT_INFO = 'info';
const VARIANT_TIP = 'tip';
-const TYPE_TO_VARIANT = {
- [FLASH_TYPES.ALERT]: VARIANT_DANGER,
- [FLASH_TYPES.NOTICE]: VARIANT_INFO,
- [FLASH_TYPES.SUCCESS]: VARIANT_SUCCESS,
- [FLASH_TYPES.WARNING]: VARIANT_WARNING,
-};
-
const FLASH_CLOSED_EVENT = 'flashClosed';
const getCloseEl = (flashEl) => {
@@ -57,27 +48,6 @@ const hideFlash = (flashEl, fadeTransition = true) => {
if (!fadeTransition) flashEl.dispatchEvent(new Event('transitionend'));
};
-const createAction = (config) => `
- <a
- href="${config.href || '#'}"
- class="flash-action"
- ${config.href ? '' : 'role="button"'}
- >
- ${escape(config.title)}
- </a>
-`;
-
-const createFlashEl = (message, type) => `
- <div class="flash-${type}" data-testid="alert-${TYPE_TO_VARIANT[type]}">
- <div class="flash-text">
- ${escape(message)}
- <div class="close-icon-wrapper js-close-icon">
- ${spriteIcon('close', 'close-icon')}
- </div>
- </div>
- </div>
-`;
-
const addDismissFlashClickListener = (flashEl, fadeTransition) => {
// There are some flash elements which do not have a closeEl.
// https://gitlab.com/gitlab-org/gitlab/blob/763426ef344488972eb63ea5be8744e0f8459e6b/ee/app/views/layouts/header/_read_only_banner.html.haml
@@ -211,73 +181,7 @@ const createAlert = function createAlert({
});
};
-/**
- * @deprecated use `createAlert` instead
- *
- * Flash banner supports different types of Flash configurations
- * along with ability to provide actionConfig which can be used to show
- * additional action or link on banner next to message
- *
- * @param {object} options - Options to control the flash message
- * @param {string} options.message - Flash message text
- * @param {'alert'|'notice'|'success'|'warning'} [options.type] - Type of Flash; it defaults to 'alert'
- * @param {Element|Document} [options.parent] - Reference to parent element under which Flash needs to appear
- * @param {object} [options.actionConfig] - Map of config to show action on banner
- * @param {string} [options.actionConfig.href] - URL to which action config should point to (default: '#')
- * @param {string} [options.actionConfig.title] - Title of action
- * @param {Function} [options.actionConfig.clickHandler] - Method to call when action is clicked on
- * @param {boolean} [options.fadeTransition] - Boolean to determine whether to fade the alert out
- * @param {boolean} [options.addBodyClass] - Adds `flash-shown` class to the `body` element
- * @param {boolean} [options.captureError] - Boolean to determine whether to send error to Sentry
- * @param {object} [options.error] - Error to be captured in Sentry
- */
-const createFlash = function createFlash({
- message,
- type = FLASH_TYPES.ALERT,
- parent = document,
- actionConfig = null,
- fadeTransition = true,
- addBodyClass = false,
- captureError = false,
- error = null,
-}) {
- const flashContainer = parent.querySelector('.flash-container');
-
- if (!flashContainer) return null;
-
- // eslint-disable-next-line no-unsanitized/property
- flashContainer.innerHTML = createFlashEl(message, type);
-
- const flashEl = flashContainer.querySelector(`.flash-${type}`);
-
- if (actionConfig) {
- // eslint-disable-next-line no-unsanitized/method
- flashEl.insertAdjacentHTML('beforeend', createAction(actionConfig));
-
- if (actionConfig.clickHandler) {
- flashEl
- .querySelector('.flash-action')
- .addEventListener('click', (e) => actionConfig.clickHandler(e));
- }
- }
-
- addDismissFlashClickListener(flashEl, fadeTransition);
-
- flashContainer.classList.add('gl-display-block');
-
- if (addBodyClass) document.body.classList.add('flash-shown');
-
- if (captureError && error) Sentry.captureException(error);
-
- flashContainer.close = () => {
- getCloseEl(flashEl).click();
- };
-
- return flashContainer;
-};
-
export {
- createFlash as default,
hideFlash,
addDismissFlashClickListener,
FLASH_TYPES,
diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue
index 8ad9eeaa266..a4e883c96b5 100644
--- a/app/assets/javascripts/frequent_items/components/app.vue
+++ b/app/assets/javascripts/frequent_items/components/app.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import AccessorUtilities from '~/lib/utils/accessor';
import {
mapVuexModuleState,
@@ -18,6 +18,11 @@ export default {
FrequentItemsSearchInput,
FrequentItemsList,
GlLoadingIcon,
+ GlButton,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
mixins: [frequentItemsMixin],
inject: ['vuexModule'],
@@ -40,12 +45,14 @@ export default {
...mapVuexModuleState((vm) => vm.vuexModule, [
'searchQuery',
'isLoadingItems',
+ 'isItemsListEditable',
'isFetchFailed',
+ 'isItemRemovalFailed',
'items',
]),
...mapVuexModuleGetters((vm) => vm.vuexModule, ['hasSearchQuery']),
translations() {
- return this.getTranslations(['loadingMessage', 'header']);
+ return this.getTranslations(['loadingMessage', 'header', 'headerEditToggle']);
},
},
created() {
@@ -74,6 +81,7 @@ export default {
...mapVuexModuleActions((vm) => vm.vuexModule, [
'setNamespace',
'setStorageKey',
+ 'toggleItemsListEditablity',
'fetchFrequentItems',
]),
dropdownOpenHandler() {
@@ -132,8 +140,25 @@ export default {
class="loading-animation prepend-top-20"
data-testid="loading"
/>
- <div v-if="!isLoadingItems && !hasSearchQuery" class="section-header" data-testid="header">
- {{ translations.header }}
+ <div
+ v-if="!isLoadingItems && !hasSearchQuery"
+ class="section-header gl-display-flex"
+ data-testid="header"
+ >
+ <span class="gl-flex-grow-1">{{ translations.header }}</span>
+ <gl-button
+ v-if="items.length"
+ v-gl-tooltip.left
+ size="small"
+ category="tertiary"
+ :aria-label="translations.headerEditToggle"
+ :title="translations.headerEditToggle"
+ :class="{ 'gl-bg-gray-100!': isItemsListEditable }"
+ class="gl-p-2!"
+ @click="toggleItemsListEditablity"
+ >
+ <gl-icon name="pencil" :class="{ 'gl-text-gray-900!': isItemsListEditable }" />
+ </gl-button>
</div>
<frequent-items-list
v-if="!isLoadingItems"
@@ -141,6 +166,7 @@ export default {
:namespace="namespace"
:has-search-query="hasSearchQuery"
:is-fetch-failed="isFetchFailed"
+ :is-item-removal-failed="isItemRemovalFailed"
:matcher="searchQuery"
/>
</div>
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue
index c0bfcf9c4a9..da1d3bedaf4 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue
@@ -21,6 +21,10 @@ export default {
type: Boolean,
required: true,
},
+ isItemRemovalFailed: {
+ type: Boolean,
+ required: true,
+ },
matcher: {
type: String,
required: true,
@@ -38,6 +42,9 @@ export default {
isListEmpty() {
return this.items.length === 0;
},
+ showListEmptyMessage() {
+ return this.isListEmpty || this.isItemRemovalFailed;
+ },
listEmptyMessage() {
if (this.hasSearchQuery) {
return this.isFetchFailed
@@ -45,7 +52,7 @@ export default {
: this.translations.searchListEmptyMessage;
}
- return this.isFetchFailed
+ return this.isFetchFailed || this.isItemRemovalFailed
? this.translations.itemListErrorMessage
: this.translations.itemListEmptyMessage;
},
@@ -60,9 +67,10 @@ export default {
<div class="frequent-items-list-container">
<ul data-testid="frequent-items-list" class="list-unstyled">
<li
- v-if="isListEmpty"
+ v-if="showListEmptyMessage"
:class="{ 'section-failure': isFetchFailed }"
class="section-empty gl-mb-3"
+ data-testid="frequent-items-list-empty"
>
{{ listEmptyMessage }}
</li>
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
index 89b6885091c..75ea9beb5cf 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
@@ -1,10 +1,10 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { snakeCase } from 'lodash';
import SafeHtml from '~/vue_shared/directives/safe_html';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
-import { mapVuexModuleState } from '~/lib/utils/vuex_module_mappers';
+import { mapVuexModuleState, mapVuexModuleActions } from '~/lib/utils/vuex_module_mappers';
import Tracking from '~/tracking';
import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
@@ -12,11 +12,13 @@ const trackingMixin = Tracking.mixin();
export default {
components: {
+ GlIcon,
GlButton,
ProjectAvatar,
},
directives: {
SafeHtml,
+ GlTooltip: GlTooltipDirective,
},
mixins: [trackingMixin],
inject: ['vuexModule'],
@@ -51,7 +53,7 @@ export default {
},
},
computed: {
- ...mapVuexModuleState((vm) => vm.vuexModule, ['dropdownType']),
+ ...mapVuexModuleState((vm) => vm.vuexModule, ['dropdownType', 'isItemsListEditable']),
truncatedNamespace() {
return truncateNamespace(this.namespace);
},
@@ -62,43 +64,63 @@ export default {
return `${this.dropdownType}_dropdown_frequent_items_list_item_${snakeCase(this.itemName)}`;
},
},
+ methods: {
+ ...mapVuexModuleActions((vm) => vm.vuexModule, ['removeFrequentItem']),
+ },
};
</script>
<template>
- <li class="frequent-items-list-item-container">
+ <li class="frequent-items-list-item-container gl-relative">
<gl-button
category="tertiary"
:href="webUrl"
- class="gl-text-left gl-justify-content-start!"
+ class="gl-text-left gl-w-full"
+ button-text-classes="gl-display-flex gl-w-full"
+ data-testid="frequent-item-link"
@click="track('click_link', { label: itemTrackingLabel })"
>
- <project-avatar
- class="gl-float-left gl-mr-3"
- :project-avatar-url="avatarUrl"
- :project-id="itemId"
- :project-name="itemName"
- aria-hidden="true"
- />
- <div
- data-testid="frequent-items-item-metadata-container"
- class="frequent-items-item-metadata-container"
- >
- <div
- v-safe-html="highlightedItemName"
- data-testid="frequent-items-item-title"
- :title="itemName"
- class="frequent-items-item-title"
- ></div>
+ <div class="gl-flex-grow-1">
+ <project-avatar
+ class="gl-float-left gl-mr-3"
+ :project-avatar-url="avatarUrl"
+ :project-id="itemId"
+ :project-name="itemName"
+ aria-hidden="true"
+ />
<div
- v-if="namespace"
- data-testid="frequent-items-item-namespace"
- :title="namespace"
- class="frequent-items-item-namespace"
+ data-testid="frequent-items-item-metadata-container"
+ class="frequent-items-item-metadata-container"
>
- {{ truncatedNamespace }}
+ <div
+ v-safe-html="highlightedItemName"
+ data-testid="frequent-items-item-title"
+ :title="itemName"
+ class="frequent-items-item-title"
+ ></div>
+ <div
+ v-if="namespace"
+ data-testid="frequent-items-item-namespace"
+ :title="namespace"
+ class="frequent-items-item-namespace"
+ >
+ {{ truncatedNamespace }}
+ </div>
</div>
</div>
</gl-button>
+ <gl-button
+ v-if="isItemsListEditable"
+ v-gl-tooltip.left
+ size="small"
+ category="tertiary"
+ :aria-label="__('Remove')"
+ :title="__('Remove')"
+ class="gl-align-self-center gl-p-1! gl-absolute! gl-w-auto! gl-top-4 gl-right-4"
+ data-testid="item-remove"
+ @click.stop.prevent="removeFrequentItem(itemId)"
+ >
+ <gl-icon name="close" />
+ </gl-button>
</li>
</template>
diff --git a/app/assets/javascripts/frequent_items/constants.js b/app/assets/javascripts/frequent_items/constants.js
index cb5d21161a9..a7c27abf58e 100644
--- a/app/assets/javascripts/frequent_items/constants.js
+++ b/app/assets/javascripts/frequent_items/constants.js
@@ -18,6 +18,7 @@ export const TRANSLATION_KEYS = {
projects: {
loadingMessage: s__('ProjectsDropdown|Loading projects'),
header: s__('ProjectsDropdown|Frequently visited'),
+ headerEditToggle: s__('ProjectsDropdown|Toggle edit mode'),
itemListErrorMessage: s__(
'ProjectsDropdown|This feature requires browser localStorage support',
),
@@ -29,6 +30,7 @@ export const TRANSLATION_KEYS = {
groups: {
loadingMessage: s__('GroupsDropdown|Loading groups'),
header: s__('GroupsDropdown|Frequently visited'),
+ headerEditToggle: s__('GroupsDropdown|Toggle edit mode'),
itemListErrorMessage: s__('GroupsDropdown|This feature requires browser localStorage support'),
itemListEmptyMessage: s__('GroupsDropdown|Groups you visit often will appear here'),
searchListErrorMessage: s__('GroupsDropdown|Something went wrong on our end.'),
diff --git a/app/assets/javascripts/frequent_items/store/actions.js b/app/assets/javascripts/frequent_items/store/actions.js
index babc2ef2e32..e5ef49ec402 100644
--- a/app/assets/javascripts/frequent_items/store/actions.js
+++ b/app/assets/javascripts/frequent_items/store/actions.js
@@ -12,6 +12,10 @@ export const setStorageKey = ({ commit }, key) => {
commit(types.SET_STORAGE_KEY, key);
};
+export const toggleItemsListEditablity = ({ commit }) => {
+ commit(types.TOGGLE_ITEMS_LIST_EDITABILITY);
+};
+
export const requestFrequentItems = ({ commit }) => {
commit(types.REQUEST_FREQUENT_ITEMS);
};
@@ -81,3 +85,28 @@ export const setSearchQuery = ({ commit, dispatch }, query) => {
dispatch('fetchFrequentItems');
}
};
+
+export const removeFrequentItemSuccess = ({ commit }, itemId) => {
+ commit(types.RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS, itemId);
+};
+
+export const removeFrequentItemError = ({ commit }) => {
+ commit(types.RECEIVE_REMOVE_FREQUENT_ITEM_ERROR);
+};
+
+export const removeFrequentItem = ({ state, dispatch }, itemId) => {
+ if (AccessorUtilities.canUseLocalStorage()) {
+ try {
+ const storedRawItems = JSON.parse(localStorage.getItem(state.storageKey));
+ localStorage.setItem(
+ state.storageKey,
+ JSON.stringify(storedRawItems.filter((item) => item.id !== itemId)),
+ );
+ dispatch('removeFrequentItemSuccess', itemId);
+ } catch {
+ dispatch('removeFrequentItemError');
+ }
+ } else {
+ dispatch('removeFrequentItemError');
+ }
+};
diff --git a/app/assets/javascripts/frequent_items/store/mutation_types.js b/app/assets/javascripts/frequent_items/store/mutation_types.js
index cbe2c9401ad..9c9346081e9 100644
--- a/app/assets/javascripts/frequent_items/store/mutation_types.js
+++ b/app/assets/javascripts/frequent_items/store/mutation_types.js
@@ -1,9 +1,12 @@
export const SET_NAMESPACE = 'SET_NAMESPACE';
export const SET_STORAGE_KEY = 'SET_STORAGE_KEY';
export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY';
+export const TOGGLE_ITEMS_LIST_EDITABILITY = 'TOGGLE_ITEMS_LIST_EDITABILITY';
export const REQUEST_FREQUENT_ITEMS = 'REQUEST_FREQUENT_ITEMS';
export const RECEIVE_FREQUENT_ITEMS_SUCCESS = 'RECEIVE_FREQUENT_ITEMS_SUCCESS';
export const RECEIVE_FREQUENT_ITEMS_ERROR = 'RECEIVE_FREQUENT_ITEMS_ERROR';
export const REQUEST_SEARCHED_ITEMS = 'REQUEST_SEARCHED_ITEMS';
export const RECEIVE_SEARCHED_ITEMS_SUCCESS = 'RECEIVE_SEARCHED_ITEMS_SUCCESS';
export const RECEIVE_SEARCHED_ITEMS_ERROR = 'RECEIVE_SEARCHED_ITEMS_ERROR';
+export const RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS = 'RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS';
+export const RECEIVE_REMOVE_FREQUENT_ITEM_ERROR = 'RECEIVE_REMOVE_FREQUENT_ITEM_ERROR';
diff --git a/app/assets/javascripts/frequent_items/store/mutations.js b/app/assets/javascripts/frequent_items/store/mutations.js
index eee00243867..65f54e6ed05 100644
--- a/app/assets/javascripts/frequent_items/store/mutations.js
+++ b/app/assets/javascripts/frequent_items/store/mutations.js
@@ -20,6 +20,11 @@ export default {
hasSearchQuery,
});
},
+ [types.TOGGLE_ITEMS_LIST_EDITABILITY](state) {
+ Object.assign(state, {
+ isItemsListEditable: !state.isItemsListEditable,
+ });
+ },
[types.REQUEST_FREQUENT_ITEMS](state) {
Object.assign(state, {
isLoadingItems: true,
@@ -69,4 +74,15 @@ export default {
isFetchFailed: true,
});
},
+ [types.RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS](state, itemId) {
+ Object.assign(state, {
+ items: state.items.filter((item) => item.id !== itemId),
+ isItemRemovalFailed: false,
+ });
+ },
+ [types.RECEIVE_REMOVE_FREQUENT_ITEM_ERROR](state) {
+ Object.assign(state, {
+ isItemRemovalFailed: true,
+ });
+ },
};
diff --git a/app/assets/javascripts/frequent_items/store/state.js b/app/assets/javascripts/frequent_items/store/state.js
index c5c0b25fdf2..ee94e9cd221 100644
--- a/app/assets/javascripts/frequent_items/store/state.js
+++ b/app/assets/javascripts/frequent_items/store/state.js
@@ -5,5 +5,7 @@ export default ({ dropdownType = '' } = {}) => ({
searchQuery: '',
isLoadingItems: false,
isFetchFailed: false,
+ isItemsListEditable: false,
+ isItemRemovalFailed: false,
items: [],
});
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 293cd2df16f..81da8409873 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -39,8 +39,18 @@ export const CONTACTS_REMOVE_COMMAND = '/remove_contacts';
* @param string user input
* @return {string} escaped user input
*/
-function escape(string) {
- return lodashEscape(string).replace(/\$/g, '&dollar;');
+export function escape(string) {
+ // To prevent double (or multiple) enconding attack
+ // Decode the user input repeatedly prior to escaping the final decoded string.
+ let encodedString = string;
+ let decodedString = decodeURIComponent(encodedString);
+
+ while (decodedString !== encodedString) {
+ encodedString = decodeURIComponent(decodedString);
+ decodedString = decodeURIComponent(encodedString);
+ }
+
+ return lodashEscape(decodedString.replace(/\$/g, '&dollar;'));
}
export function showAndHideHelper($input, alias = '') {
@@ -106,6 +116,7 @@ export const defaultAutocompleteConfig = {
issues: true,
mergeRequests: true,
epics: true,
+ iterations: true,
milestones: true,
labels: true,
snippets: true,
@@ -209,6 +220,10 @@ class GfmAutoComplete {
[[referencePrefix]] = value.params;
if (/^[@%~]/.test(referencePrefix)) {
tpl += '<%- referencePrefix %>';
+ } else if (/^[*]/.test(referencePrefix)) {
+ // EE-ONLY
+ referencePrefix = '*iteration:';
+ tpl += '<%- referencePrefix %>';
}
}
}
@@ -883,7 +898,8 @@ class GfmAutoComplete {
const atSymbolsWithBar = Object.keys(controllers)
.join('|')
.replace(/[$]/, '\\$&')
- .replace(/([[\]:])/g, '\\$1');
+ .replace(/([[\]:])/g, '\\$1')
+ .replace(/([*])/g, '\\$1');
const atSymbolsWithoutBar = Object.keys(controllers).join('');
const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop();
@@ -912,6 +928,7 @@ GfmAutoComplete.atTypeMap = {
'#': 'issues',
'!': 'mergeRequests',
'&': 'epics',
+ '*iteration:': 'iterations',
'~': 'labels',
'%': 'milestones',
'/': 'commands',
diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js
index 15e7ef7d62c..01cc2fc3018 100644
--- a/app/assets/javascripts/graphql_shared/issuable_client.js
+++ b/app/assets/javascripts/graphql_shared/issuable_client.js
@@ -5,6 +5,7 @@ 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_NOTES } from '~/work_items/constants';
export const config = {
typeDefs,
@@ -22,10 +23,30 @@ export const config = {
},
},
},
+ WorkItemWidgetNotes: {
+ fields: {
+ // If we add any key args, the discussions field becomes discussions({"filter":"ONLY_ACTIVITY","first":10}) and
+ // kills any possibility to handle it on the widget level without hardcoding a string.
+ discussions: {
+ keyArgs: false,
+ },
+ },
+ },
+ WorkItemWidgetProgress: {
+ fields: {
+ progress: {
+ // We want to show null progress as 0 as per https://gitlab.com/gitlab-org/gitlab/-/issues/386117
+ read(existing) {
+ return existing === null ? 0 : existing;
+ },
+ },
+ },
+ },
WorkItem: {
fields: {
+ // widgets policy because otherwise the subscriptions invalidate the cache
widgets: {
- merge(existing = [], incoming) {
+ merge(existing = [], incoming, context) {
if (existing.length === 0) {
return incoming;
}
@@ -33,6 +54,24 @@ export const config = {
const incomingWidget = incoming.find(
(w) => w.type && w.type === existingWidget.type,
);
+ // We don't want to override existing notes with empty widget on work item updates
+ if (incomingWidget?.type === WIDGET_TYPE_NOTES && !context.variables.pageSize) {
+ return existingWidget;
+ }
+ // we want to concat next page of discussions to the existing ones
+ if (incomingWidget?.type === WIDGET_TYPE_NOTES && context.variables.after) {
+ // concatPagination won't work because we were placing new widget here so we have to do this manually
+ return {
+ ...incomingWidget,
+ discussions: {
+ ...incomingWidget.discussions,
+ nodes: [
+ ...existingWidget.discussions.nodes,
+ ...incomingWidget.discussions.nodes,
+ ],
+ },
+ };
+ }
return incomingWidget || existingWidget;
});
},
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index 5467105ac3c..a622b342c0a 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -153,8 +153,9 @@
"WorkItemWidgetMilestone",
"WorkItemWidgetNotes",
"WorkItemWidgetProgress",
+ "WorkItemWidgetRequirementLegacy",
"WorkItemWidgetStartAndDueDate",
"WorkItemWidgetStatus",
"WorkItemWidgetWeight"
]
-} \ No newline at end of file
+}
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
deleted file mode 100644
index fb0c47fe018..00000000000
--- a/app/assets/javascripts/groups_select.js
+++ /dev/null
@@ -1,122 +0,0 @@
-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(() => {
- // Needs to be accessible in rspec
- window.GROUP_SELECT_PER_PAGE = 20;
-
- $('.ajax-groups-select').each(function setAjaxGroupsSelect2() {
- const $select = $(this);
- const allAvailable = $select.data('allAvailable');
- const skipGroups = $select.data('skipGroups') || [];
- const parentGroupID = $select.data('parentId');
- const groupsFilter = $select.data('groupsFilter');
- const minAccessLevel = $select.data('minAccessLevel');
-
- $select.select2({
- placeholder: __('Search for a group'),
- allowClear: $select.hasClass('allowClear'),
- multiple: $select.hasClass('multiselect'),
- minimumInputLength: 0,
- ajax: {
- url: Api.buildUrl(groupsPath(groupsFilter, parentGroupID)),
- dataType: 'json',
- quietMillis: 250,
- transport: select2AxiosTransport,
- data(search, page) {
- return {
- search,
- page,
- per_page: window.GROUP_SELECT_PER_PAGE,
- all_available: allAvailable,
- min_access_level: minAccessLevel,
- };
- },
- results(data, page) {
- const groups = data.length ? data : data.results || [];
- const more = data.pagination ? data.pagination.more : false;
- const results = groups.filter((group) => skipGroups.indexOf(group.id) === -1);
-
- return {
- results,
- page,
- more,
- };
- },
- },
- // eslint-disable-next-line consistent-return
- initSelection(element, callback) {
- const id = $(element).val();
- if (id !== '') {
- return Api.group(id, callback);
- }
- },
- formatResult(object) {
- return `<div class='group-result'> <div class='group-name'>${escape(
- object.full_name,
- )}</div> <div class='group-path'>${object.full_path}</div> </div>`;
- },
- formatSelection(object) {
- return escape(object.full_name);
- },
- dropdownCssClass: 'ajax-groups-dropdown select2-infinite',
- // we do not want to escape markup since we are displaying html in results
- escapeMarkup(m) {
- return m;
- },
- });
-
- $select.on('select2-loaded', () => {
- const dropdown = document.querySelector('.select2-infinite .select2-results');
- dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`;
- });
- });
- })
- .catch(() => {});
-};
-
-export default () => {
- if ($('.ajax-groups-select').length) {
- if (gon.features?.vueGroupSelect) {
- initVueSelect();
- } else {
- import(/* webpackChunkName: 'select2' */ 'select2/select2')
- .then(groupsSelect)
- .catch(() => {});
- }
- }
-};
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 3c9c0b1ade1..b95f8bb5acb 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -27,7 +27,6 @@ import { performanceMarkAndMeasure } from '~/performance/utils';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
leftSidebarViews,
viewerTypes,
@@ -55,7 +54,6 @@ export default {
DiffViewer,
FileTemplatesBar,
},
- mixins: [glFeatureFlagMixin()],
props: {
file: {
type: Object,
@@ -474,7 +472,7 @@ export default {
this.editor.registerCiSchema();
};
- if (this.isCiConfigFile && this.glFeatures.schemaLinting) {
+ if (this.isCiConfigFile) {
registerLocalSchema();
} else {
if (this.CiSchemaExtension) {
diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js
index fbd2ce4ce69..dbb68b7facd 100644
--- a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js
+++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js
@@ -1,7 +1,12 @@
-import { cleanEndingSeparator } from '~/lib/utils/url_utility';
+import { cleanEndingSeparator, joinPaths } from '~/lib/utils/url_utility';
const getBaseUrl = () => {
- const baseUrlObj = new URL(process.env.GITLAB_WEB_IDE_PUBLIC_PATH, window.location.origin);
+ const path = joinPaths(
+ '/',
+ window.gon.relative_url_root || '',
+ process.env.GITLAB_WEB_IDE_PUBLIC_PATH,
+ );
+ const baseUrlObj = new URL(path, window.location.origin);
return cleanEndingSeparator(baseUrlObj.href);
};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index cbc6e0fe519..d490b8c5dad 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -183,7 +183,11 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
dispatch(
'redirectToUrl',
- createNewMergeRequestUrl(currentProject.web_url, branchName, targetBranch),
+ createNewMergeRequestUrl(
+ currentProject.web_url,
+ encodeURIComponent(branchName),
+ encodeURIComponent(targetBranch),
+ ),
{ root: true },
);
}
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
index 62476b7fc63..6eb56a68429 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
@@ -1,6 +1,6 @@
import axios from 'axios';
import Visibility from 'visibilityjs';
-import httpStatus from '~/lib/utils/http_status';
+import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status';
import Poll from '~/lib/utils/poll';
import { __ } from '~/locale';
import { rightSidebarViews } from '../../../constants';
@@ -24,7 +24,7 @@ export const forcePipelineRequest = () => {
export const requestLatestPipeline = ({ commit }) => commit(types.REQUEST_LATEST_PIPELINE);
export const receiveLatestPipelineError = ({ commit, dispatch }, err) => {
- if (err.status !== httpStatus.NOT_FOUND) {
+ if (err.status !== HTTP_STATUS_NOT_FOUND) {
dispatch(
'setErrorMessage',
{
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js
index 91645a34a3d..c4198a7427f 100644
--- a/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js
+++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js
@@ -1,5 +1,5 @@
import Api from '~/api';
-import httpStatus from '~/lib/utils/http_status';
+import { HTTP_STATUS_FORBIDDEN, HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status';
import * as terminalService from '../../../../services/terminals';
import { CHECK_CONFIG, CHECK_RUNNERS, RETRY_RUNNERS_INTERVAL } from '../constants';
import * as messages from '../messages';
@@ -18,7 +18,7 @@ export const receiveConfigCheckError = ({ commit, state }, e) => {
const { status } = e.response;
const { paths } = state;
- const isVisible = status !== httpStatus.FORBIDDEN && status !== httpStatus.NOT_FOUND;
+ const isVisible = status !== HTTP_STATUS_FORBIDDEN && status !== HTTP_STATUS_NOT_FOUND;
commit(types.SET_VISIBLE, isVisible);
const message = messages.configCheckError(status, paths.webTerminalConfigHelpPath);
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js
index a510ec0847b..874cc5094d3 100644
--- a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js
+++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js
@@ -1,6 +1,6 @@
import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
-import httpStatus, { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status';
+import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status';
import * as terminalService from '../../../../services/terminals';
import { STARTING, STOPPING, STOPPED } from '../constants';
import * as messages from '../messages';
@@ -107,7 +107,7 @@ export const restartSession = ({ state, dispatch, rootState }) => {
const responseStatus = error.response && error.response.status;
// We may have removed the build, in this case we'll just create a new session
if (
- responseStatus === httpStatus.NOT_FOUND ||
+ responseStatus === HTTP_STATUS_NOT_FOUND ||
responseStatus === HTTP_STATUS_UNPROCESSABLE_ENTITY
) {
dispatch('startSession');
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/messages.js b/app/assets/javascripts/ide/stores/modules/terminal/messages.js
index fa1c7f23677..ad7ad35a98c 100644
--- a/app/assets/javascripts/ide/stores/modules/terminal/messages.js
+++ b/app/assets/javascripts/ide/stores/modules/terminal/messages.js
@@ -1,5 +1,5 @@
import { escape } from 'lodash';
-import httpStatus, { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status';
+import { HTTP_STATUS_FORBIDDEN, HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status';
import { __, sprintf } from '~/locale';
export const UNEXPECTED_ERROR_CONFIG = __(
@@ -39,7 +39,7 @@ export const configCheckError = (status, helpUrl) => {
},
false,
);
- } else if (status === httpStatus.FORBIDDEN) {
+ } else if (status === HTTP_STATUS_FORBIDDEN) {
return ERROR_PERMISSION;
}
diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue
index bd69165f0ca..db677c574d1 100644
--- a/app/assets/javascripts/import_entities/components/import_status.vue
+++ b/app/assets/javascripts/import_entities/components/import_status.vue
@@ -7,13 +7,21 @@ import { STATUSES } from '../constants';
const STATISTIC_ITEMS = {
diff_note: __('Diff notes'),
issue: __('Issues'),
+ issue_attachment: s__('GithubImporter|Issue attachments'),
+ issue_event: __('Issue events'),
label: __('Labels'),
+ lfs_object: __('LFS objects'),
+ merge_request_attachment: s__('GithubImporter|Merge request attachments'),
milestone: __('Milestones'),
note: __('Notes'),
+ note_attachment: s__('GithubImporter|Note attachments'),
+ protected_branch: __('Protected branches'),
pull_request: s__('GithubImporter|Pull requests'),
pull_request_merged_by: s__('GithubImporter|PR mergers'),
pull_request_review: s__('GithubImporter|PR reviews'),
+ pull_request_review_request: s__('GithubImporter|PR reviews'),
release: __('Releases'),
+ release_attachment: s__('GithubImporter|Release attachments'),
};
// support both camel case and snake case versions
@@ -93,18 +101,17 @@ export default {
mappedStatus() {
if (this.status === STATUSES.FINISHED) {
const isIncomplete = this.stats && isIncompleteImport(this.stats);
- return {
- icon: 'status-success',
- ...(isIncomplete
- ? {
- text: __('Partial import'),
- variant: 'warning',
- }
- : {
- text: __('Complete'),
- variant: 'success',
- }),
- };
+ return isIncomplete
+ ? {
+ icon: 'status-alert',
+ text: __('Partial import'),
+ variant: 'warning',
+ }
+ : {
+ icon: 'status-success',
+ text: __('Complete'),
+ variant: 'success',
+ };
}
return STATUS_MAP[this.status];
@@ -120,6 +127,8 @@ export default {
return { name: 'status-success', class: 'gl-text-green-400' };
} else if (imported === 0) {
return { name: 'status-scheduled', class: 'gl-text-gray-400' };
+ } else if (this.status === STATUSES.FINISHED) {
+ return { name: 'status-alert', class: 'gl-text-orange-400' };
}
return { name: 'status-running', class: 'gl-text-blue-400' };
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
index deaf2654424..8d72942447c 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
@@ -1,15 +1,27 @@
<script>
-import { GlButton, GlIcon, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlTooltipDirective as GlTooltip,
+} from '@gitlab/ui';
export default {
components: {
GlIcon,
GlButton,
+ GlDropdown,
+ GlDropdownItem,
},
directives: {
GlTooltip,
},
props: {
+ isProjectsImportEnabled: {
+ type: Boolean,
+ required: true,
+ },
isFinished: {
type: Boolean,
required: true,
@@ -23,13 +35,32 @@ export default {
required: true,
},
},
+ methods: {
+ importGroup(extraArgs = {}) {
+ this.$emit('import-group', extraArgs);
+ },
+ },
};
</script>
<template>
<span class="gl-white-space-nowrap gl-inline-flex gl-align-items-center">
+ <gl-dropdown
+ v-if="isProjectsImportEnabled && isAvailableForImport"
+ :text="isFinished ? __('Re-import with projects') : __('Import with projects')"
+ :disabled="isInvalid"
+ variant="confirm"
+ category="secondary"
+ data-qa-selector="import_group_button"
+ split
+ @click="importGroup({ migrateProjects: true })"
+ >
+ <gl-dropdown-item @click="importGroup({ migrateProjects: false })">{{
+ isFinished ? __('Re-import without projects') : __('Import without projects')
+ }}</gl-dropdown-item>
+ </gl-dropdown>
<gl-button
- v-if="isAvailableForImport"
+ v-else-if="isAvailableForImport"
:disabled="isInvalid"
variant="confirm"
category="secondary"
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 6412f26fde7..c590d832568 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
@@ -2,6 +2,8 @@
import {
GlAlert,
GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlEmptyState,
GlIcon,
GlLink,
@@ -15,6 +17,7 @@ import {
import { debounce } from 'lodash';
import { createAlert } from '~/flash';
import { s__, __, n__, sprintf } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
import HelpPopover from '~/vue_shared/components/help_popover.vue';
import { getGroupPathAvailability } from '~/rest_api';
@@ -47,6 +50,8 @@ export default {
components: {
GlAlert,
GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlEmptyState,
GlIcon,
GlLink,
@@ -65,6 +70,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
sourceUrl: {
type: String,
@@ -128,32 +134,36 @@ export default {
{
key: 'webUrl',
label: s__('BulkImport|Source group'),
- thClass: `${DEFAULT_TH_CLASSES} gl-pl-0! import-jobs-from-col`,
+ thClass: `${DEFAULT_TH_CLASSES} gl-pl-0! gl-w-half`,
// eslint-disable-next-line @gitlab/require-i18n-strings
tdClass: `${DEFAULT_TD_CLASSES} gl-pl-0!`,
},
{
key: 'importTarget',
label: s__('BulkImport|New group'),
- thClass: `${DEFAULT_TH_CLASSES} import-jobs-to-col`,
+ thClass: `${DEFAULT_TH_CLASSES} gl-w-half`,
tdClass: DEFAULT_TD_CLASSES,
},
{
key: 'progress',
label: __('Status'),
- thClass: `${DEFAULT_TH_CLASSES} import-jobs-status-col`,
+ thClass: `${DEFAULT_TH_CLASSES}`,
tdClass: DEFAULT_TD_CLASSES,
tdAttr: { 'data-qa-selector': 'import_status_indicator' },
},
{
key: 'actions',
label: '',
- thClass: `${DEFAULT_TH_CLASSES} import-jobs-cta-col`,
+ thClass: `${DEFAULT_TH_CLASSES}`,
tdClass: DEFAULT_TD_CLASSES,
},
],
computed: {
+ isProjectsImportEnabled() {
+ return Boolean(this.glFeatures.bulkImportProjects);
+ },
+
groups() {
return this.bulkImportSourceGroups?.nodes ?? [];
},
@@ -260,7 +270,7 @@ export default {
const table = this.getTableRef();
const matches = new Set();
this.groups.forEach((g, idx) => {
- if (!this.importGroups[g.id]) {
+ if (!this.importTargets[g.id]) {
this.setDefaultImportTarget(g);
}
@@ -375,13 +385,14 @@ export default {
}
},
- importSelectedGroups() {
+ importSelectedGroups(extraArgs = {}) {
const importRequests = this.groupsTableData
.filter((group) => this.selectedGroupsIds.includes(group.id))
.map((group) => ({
sourceGroupId: group.id,
targetNamespace: group.importTarget.targetNamespace.fullPath,
newName: group.importTarget.newName,
+ ...extraArgs,
}));
this.importGroups(importRequests);
@@ -521,6 +532,7 @@ export default {
gitlabLogo: window.gon.gitlab_logo,
PAGE_SIZES,
permissionsHelpPath: helpPagePath('user/permissions', { anchor: 'group-members-permissions' }),
+ betaFeatureHelpPath: helpPagePath('policy/alpha-beta-support', { anchor: 'beta-features' }),
popoverOptions: { title: __('What is listed here?') },
i18n,
LOCAL_STORAGE_KEY: 'gl-bulk-imports-status-page-size-v1',
@@ -637,7 +649,7 @@ export default {
</gl-empty-state>
<template v-else>
<div
- class="gl-bg-gray-10 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-px-4 gl-display-flex gl-align-items-center import-table-bar"
+ class="gl-bg-gray-10 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-px-4 gl-display-flex gl-align-items-center gl-sticky gl-z-index-3 import-table-bar"
>
<span data-test-id="selection-count">
<gl-sprintf :message="__('%{count} selected')">
@@ -646,7 +658,22 @@ export default {
</template>
</gl-sprintf>
</span>
+ <gl-dropdown
+ v-if="isProjectsImportEnabled"
+ :text="s__('BulkImport|Import with projects')"
+ :disabled="!hasSelectedGroups"
+ variant="confirm"
+ category="primary"
+ class="gl-ml-4"
+ split
+ @click="importSelectedGroups({ migrateProjects: true })"
+ >
+ <gl-dropdown-item @click="importSelectedGroups({ migrateProjects: false })">
+ {{ s__('BulkImport|Import without projects') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
<gl-button
+ v-else
category="primary"
variant="confirm"
class="gl-ml-4"
@@ -654,6 +681,22 @@ export default {
@click="importSelectedGroups"
>{{ s__('BulkImport|Import selected') }}</gl-button
>
+ <span class="gl-ml-3">
+ <gl-icon name="information-o" :size="12" class="gl-text-blue-600" />
+ <gl-sprintf
+ :message="
+ s__(
+ 'BulkImport|Importing projects is a %{docsLinkStart}Beta%{docsLinkEnd} feature.',
+ )
+ "
+ >
+ <template #docsLink="{ content }"
+ ><gl-link :href="$options.betaFeatureHelpPath" target="_blank">{{
+ content
+ }}</gl-link></template
+ >
+ </gl-sprintf>
+ </span>
</div>
<gl-table
ref="table"
@@ -661,6 +704,7 @@ export default {
data-qa-selector="import_table"
:tbody-tr-class="rowClasses"
:tbody-tr-attr="qaRowAttributes"
+ thead-class="gl-sticky gl-z-index-2 gl-bg-gray-10"
:items="groupsTableData"
:fields="$options.fields"
selectable
@@ -711,6 +755,7 @@ export default {
</template>
<template #cell(actions)="{ item: group }">
<import-actions-cell
+ :is-projects-import-enabled="isProjectsImportEnabled"
:is-finished="group.flags.isFinished"
:is-available-for-import="group.flags.isAvailableForImport"
:is-invalid="group.flags.isInvalid"
@@ -720,6 +765,7 @@ export default {
sourceGroupId: group.id,
targetNamespace: group.importTarget.targetNamespace.fullPath,
newName: group.importTarget.newName,
+ ...$event,
},
])
"
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
index 913a5a659b3..de0595360bf 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
@@ -153,6 +153,7 @@ export function createResolvers({ endpoints }) {
source_full_path: op.group.fullPath,
destination_namespace: op.targetNamespace,
destination_name: op.newName,
+ migrate_projects: op.migrateProjects,
})),
});
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql
index c48e22a7717..83d17a5baa7 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql
@@ -74,6 +74,7 @@ input ImportRequestInput {
sourceGroupId: ID!
targetNamespace: String!
newName: String!
+ migrateProjects: Boolean!
}
extend type Mutation {
diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
index b8faf349375..da5dcfa71e3 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
@@ -140,7 +140,7 @@ export default {
>
<template v-if="repo.importSource.target">{{ repo.importSource.target }}</template>
<template v-else-if="isImportNotStarted">
- <div class="import-entities-target-select gl-display-flex gl-align-items-stretch gl-w-full">
+ <div class="gl-display-flex gl-align-items-stretch gl-w-full">
<import-group-dropdown #default="{ namespaces }" :text="importTarget.targetNamespace">
<template v-if="namespaces.length">
<gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
@@ -161,7 +161,7 @@ export default {
}}</gl-dropdown-item>
</import-group-dropdown>
<div
- class="import-entities-target-select-separator gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
+ class="gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
>
/
</div>
diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js
index 197fb03af2c..485511510f7 100644
--- a/app/assets/javascripts/import_entities/import_projects/index.js
+++ b/app/assets/javascripts/import_entities/import_projects/index.js
@@ -57,7 +57,11 @@ const apolloProvider = new VueApollo({
defaultClient,
});
-export default function mountImportProjectsTable(mountElement) {
+export default function mountImportProjectsTable({
+ mountElement,
+ Component = ImportProjectsTable,
+ extraProps = () => ({}),
+}) {
if (!mountElement) return undefined;
const store = initStoreFromElement(mountElement);
@@ -68,7 +72,7 @@ export default function mountImportProjectsTable(mountElement) {
store,
apolloProvider,
render(createElement) {
- return createElement(ImportProjectsTable, { props });
+ return createElement(Component, { props: { ...props, ...extraProps(mountElement.dataset) } });
},
});
}
diff --git a/app/assets/javascripts/init_diff_stats_dropdown.js b/app/assets/javascripts/init_diff_stats_dropdown.js
index 27df761a103..8413fe92f89 100644
--- a/app/assets/javascripts/init_diff_stats_dropdown.js
+++ b/app/assets/javascripts/init_diff_stats_dropdown.js
@@ -4,7 +4,7 @@ import { stickyMonitor } from './lib/utils/sticky';
export const initDiffStatsDropdown = (stickyTop) => {
if (stickyTop) {
- stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop);
+ stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop, false);
}
const el = document.querySelector('.js-diff-stats-dropdown');
diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js
index 392dd63b089..b956bdf067d 100644
--- a/app/assets/javascripts/integrations/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -52,6 +52,7 @@ export const integrationTriggerEvents = {
TAG_PUSH: 'tag_push_events',
PIPELINE: 'pipeline_events',
WIKI_PAGE: 'wiki_page_events',
+ DEPLOYMENT: 'deployment_events',
};
export const integrationTriggerEventTitles = {
@@ -72,6 +73,9 @@ export const integrationTriggerEventTitles = {
[integrationTriggerEvents.TAG_PUSH]: s__('IntegrationEvents|A tag is pushed to the repository'),
[integrationTriggerEvents.PIPELINE]: s__('IntegrationEvents|A pipeline status changes'),
[integrationTriggerEvents.WIKI_PAGE]: s__('IntegrationEvents|A wiki page is created or updated'),
+ [integrationTriggerEvents.DEPLOYMENT]: s__(
+ 'IntegrationEvents|A deployment is started or finished',
+ ),
};
export const billingPlans = {
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index d86e6326f64..1e58b604bf7 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlBadge, GlButton, GlForm } from '@gitlab/ui';
+import { GlAlert, GlForm } from '@gitlab/ui';
import axios from 'axios';
import * as Sentry from '@sentry/browser';
import { mapState, mapActions, mapGetters } from 'vuex';
@@ -10,8 +10,6 @@ import {
I18N_DEFAULT_ERROR_MESSAGE,
I18N_SUCCESSFUL_CONNECTION_MESSAGE,
INTEGRATION_FORM_TYPE_SLACK,
- integrationFormSectionComponents,
- billingPlanNames,
} from '~/integrations/constants';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import csrf from '~/lib/utils/csrf';
@@ -21,6 +19,7 @@ import ActiveCheckbox from './active_checkbox.vue';
import DynamicField from './dynamic_field.vue';
import OverrideDropdown from './override_dropdown.vue';
import TriggerFields from './trigger_fields.vue';
+import IntegrationFormSection from './integration_forms/section.vue';
import IntegrationFormActions from './integration_form_actions.vue';
export default {
@@ -31,29 +30,8 @@ export default {
TriggerFields,
DynamicField,
IntegrationFormActions,
- IntegrationSectionConfiguration: () =>
- import(
- /* webpackChunkName: 'integrationSectionConfiguration' */ '~/integrations/edit/components/sections/configuration.vue'
- ),
- IntegrationSectionConnection: () =>
- import(
- /* webpackChunkName: 'integrationSectionConnection' */ '~/integrations/edit/components/sections/connection.vue'
- ),
- IntegrationSectionJiraIssues: () =>
- import(
- /* webpackChunkName: 'integrationSectionJiraIssues' */ '~/integrations/edit/components/sections/jira_issues.vue'
- ),
- IntegrationSectionJiraTrigger: () =>
- import(
- /* webpackChunkName: 'integrationSectionJiraTrigger' */ '~/integrations/edit/components/sections/jira_trigger.vue'
- ),
- IntegrationSectionTrigger: () =>
- import(
- /* webpackChunkName: 'integrationSectionTrigger' */ '~/integrations/edit/components/sections/trigger.vue'
- ),
+ IntegrationFormSection,
GlAlert,
- GlBadge,
- GlButton,
GlForm,
},
directives: {
@@ -120,9 +98,6 @@ export default {
},
methods: {
...mapActions(['setOverride', 'requestJiraIssueTypes']),
- fieldsForSection(section) {
- return this.propsSource.fields.filter((field) => field.section === section.type);
- },
form() {
return this.$refs.integrationForm.$el;
},
@@ -189,23 +164,21 @@ export default {
this.isResetting = false;
});
},
- onRequestJiraIssueTypes() {
- this.requestJiraIssueTypes(this.getFormData());
- },
getFormData() {
return new FormData(this.form());
},
onToggleIntegrationState(integrationActive) {
this.integrationActive = integrationActive;
},
+ onRequestJiraIssueTypes() {
+ this.requestJiraIssueTypes(this.getFormData());
+ },
},
helpHtmlConfig: {
ADD_TAGS: ['use'], // to support icon SVGs
FORBID_ATTR: [], // This is trusted input so we can override the default config to allow data-* attributes
},
csrf,
- integrationFormSectionComponents,
- billingPlanNames,
slackUpgradeInfo: {
title: s__(
`SlackIntegration|Update to the latest version of GitLab for Slack to get notifications`,
@@ -280,42 +253,15 @@ export default {
</div>
<template v-if="hasSections">
- <div
+ <integration-form-section
v-for="(section, index) in customState.sections"
:key="section.type"
+ :section="section"
+ :is-validated="isValidated"
:class="{ 'gl-border-b gl-pb-3 gl-mb-6': index !== customState.sections.length - 1 }"
- data-testid="integration-section"
- >
- <section class="gl-lg-display-flex">
- <div class="gl-flex-basis-third gl-mr-4">
- <h4 class="gl-mt-0">
- {{ section.title
- }}<gl-badge
- v-if="section.plan"
- :href="propsSource.aboutPricingUrl"
- target="_blank"
- rel="noopener noreferrer"
- variant="tier"
- icon="license"
- class="gl-ml-3"
- >
- {{ $options.billingPlanNames[section.plan] }}
- </gl-badge>
- </h4>
- <p v-safe-html="section.description"></p>
- </div>
-
- <div class="gl-flex-basis-two-thirds">
- <component
- :is="$options.integrationFormSectionComponents[section.type]"
- :fields="fieldsForSection(section)"
- :is-validated="isValidated"
- @toggle-integration-active="onToggleIntegrationState"
- @request-jira-issue-types="onRequestJiraIssueTypes"
- />
- </div>
- </section>
- </div>
+ @toggle-integration-active="onToggleIntegrationState"
+ @request-jira-issue-types="onRequestJiraIssueTypes"
+ />
</template>
<section v-if="hasFieldsWithoutSection" class="gl-lg-display-flex gl-justify-content-end">
diff --git a/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue
new file mode 100644
index 00000000000..ce39954735a
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue
@@ -0,0 +1,90 @@
+<script>
+import { GlBadge } from '@gitlab/ui';
+import { mapGetters } from 'vuex';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { integrationFormSectionComponents, billingPlanNames } from '~/integrations/constants';
+
+export default {
+ name: 'IntegrationFormSection',
+ components: {
+ GlBadge,
+ IntegrationSectionConfiguration: () =>
+ import(
+ /* webpackChunkName: 'integrationSectionConfiguration' */ '~/integrations/edit/components/sections/configuration.vue'
+ ),
+ IntegrationSectionConnection: () =>
+ import(
+ /* webpackChunkName: 'integrationSectionConnection' */ '~/integrations/edit/components/sections/connection.vue'
+ ),
+ IntegrationSectionJiraIssues: () =>
+ import(
+ /* webpackChunkName: 'integrationSectionJiraIssues' */ '~/integrations/edit/components/sections/jira_issues.vue'
+ ),
+ IntegrationSectionJiraTrigger: () =>
+ import(
+ /* webpackChunkName: 'integrationSectionJiraTrigger' */ '~/integrations/edit/components/sections/jira_trigger.vue'
+ ),
+ IntegrationSectionTrigger: () =>
+ import(
+ /* webpackChunkName: 'integrationSectionTrigger' */ '~/integrations/edit/components/sections/trigger.vue'
+ ),
+ },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ section: {
+ type: Object,
+ required: true,
+ },
+ isValidated: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters(['propsSource']),
+ },
+ methods: {
+ fieldsForSection(section) {
+ return this.propsSource.fields.filter((field) => field.section === section.type);
+ },
+ },
+ billingPlanNames,
+ integrationFormSectionComponents,
+};
+</script>
+<template>
+ <section class="gl-lg-display-flex">
+ <div class="gl-flex-basis-third gl-mr-4">
+ <h4 class="gl-mt-0">
+ {{ section.title
+ }}<gl-badge
+ v-if="section.plan"
+ :href="propsSource.aboutPricingUrl"
+ target="_blank"
+ rel="noopener noreferrer"
+ variant="tier"
+ icon="license"
+ class="gl-ml-3"
+ >
+ {{ $options.billingPlanNames[section.plan] }}
+ </gl-badge>
+ </h4>
+ <p v-safe-html="section.description"></p>
+ </div>
+
+ <div
+ v-if="$options.integrationFormSectionComponents[section.type]"
+ class="gl-flex-basis-two-thirds"
+ >
+ <component
+ :is="$options.integrationFormSectionComponents[section.type]"
+ :fields="fieldsForSection(section)"
+ :is-validated="isValidated"
+ @toggle-integration-active="$emit('toggle-integration-active', $event)"
+ @request-jira-issue-types="$emit('request-jira-issue-types', $event)"
+ />
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/sections/trigger.vue b/app/assets/javascripts/integrations/edit/components/sections/trigger.vue
index 9af5070d4cf..00546671aa7 100644
--- a/app/assets/javascripts/integrations/edit/components/sections/trigger.vue
+++ b/app/assets/javascripts/integrations/edit/components/sections/trigger.vue
@@ -15,11 +15,12 @@ export default {
</script>
<template>
- <div>
+ <div data-testid="trigger-fields-group">
<trigger-field
v-for="event in propsSource.triggerEvents"
:key="`${currentKey}-trigger-fields-${event.name}`"
:event="event"
+ :type="propsSource.type"
class="gl-mb-3"
/>
</div>
diff --git a/app/assets/javascripts/integrations/edit/components/trigger_field.vue b/app/assets/javascripts/integrations/edit/components/trigger_field.vue
index dc5ae2f3a3d..57753c61587 100644
--- a/app/assets/javascripts/integrations/edit/components/trigger_field.vue
+++ b/app/assets/javascripts/integrations/edit/components/trigger_field.vue
@@ -1,13 +1,17 @@
<script>
-import { GlFormCheckbox } from '@gitlab/ui';
+import { GlFormCheckbox, GlFormInput } from '@gitlab/ui';
import { mapGetters } from 'vuex';
-import { integrationTriggerEventTitles } from '~/integrations/constants';
+import {
+ placeholderForType,
+ integrationTriggerEventTitles,
+} from 'any_else_ce/integrations/constants';
export default {
name: 'TriggerField',
components: {
GlFormCheckbox,
+ GlFormInput,
},
props: {
event: {
@@ -15,10 +19,15 @@ export default {
required: false,
default: () => ({}),
},
+ type: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
value: false,
+ fieldValue: this.event.field?.value,
};
},
computed: {
@@ -26,9 +35,15 @@ export default {
name() {
return `service[${this.event.name}]`;
},
+ fieldName() {
+ return `service[${this.event.field?.name}]`;
+ },
title() {
return integrationTriggerEventTitles[this.event.name];
},
+ defaultPlaceholder() {
+ return placeholderForType[this.type];
+ },
},
mounted() {
this.value = this.event.value || false;
@@ -42,5 +57,15 @@ export default {
<gl-form-checkbox v-model="value" :disabled="isInheriting">
{{ title }}
</gl-form-checkbox>
+ <div class="gl-ml-6">
+ <gl-form-input
+ v-if="event.field"
+ v-show="value"
+ v-model="fieldValue"
+ :name="fieldName"
+ :placeholder="event.field.placeholder || defaultPlaceholder"
+ :readonly="isInheriting"
+ />
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index fbb547c28ff..fa1aa6b0d88 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -18,8 +18,6 @@ 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_VARIANT,
- REACHED_LIMIT_VARIANT,
USERS_FILTER_ALL,
INVITE_MEMBERS_FOR_TASK,
MEMBER_MODAL_LABELS,
@@ -189,10 +187,10 @@ export default {
return this.source === LEARN_GITLAB;
},
showUserLimitNotification() {
- return this.usersLimitDataset.reachedLimit || this.usersLimitDataset.closeToDashboardLimit;
+ return !isEmpty(this.usersLimitDataset.alertVariant);
},
limitVariant() {
- return this.usersLimitDataset.reachedLimit ? REACHED_LIMIT_VARIANT : CLOSE_TO_LIMIT_VARIANT;
+ return this.usersLimitDataset.alertVariant;
},
errorList() {
return Object.entries(this.invalidMembers).map(([member, error]) => {
@@ -479,6 +477,7 @@ export default {
</gl-alert>
<user-limit-notification
v-else-if="showUserLimitNotification"
+ class="gl-mb-5"
:limit-variant="limitVariant"
:users-limit-dataset="usersLimitDataset"
/>
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 515dd3de319..1d061a4b81e 100644
--- a/app/assets/javascripts/invite_members/components/user_limit_notification.vue
+++ b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
@@ -1,14 +1,18 @@
<script>
import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
import { n__, sprintf } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
import {
+ INFO_ALERT_TITLE,
WARNING_ALERT_TITLE,
DANGER_ALERT_TITLE,
REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE,
REACHED_LIMIT_VARIANT,
CLOSE_TO_LIMIT_MESSAGE,
CLOSE_TO_LIMIT_VARIANT,
+ NOTIFICATION_LIMIT_MESSAGE,
+ NOTIFICATION_LIMIT_VARIANT,
} from '../constants';
export default {
@@ -28,6 +32,15 @@ export default {
computed: {
limitAttributes() {
return {
+ [NOTIFICATION_LIMIT_VARIANT]: {
+ variant: 'info',
+ title: this.notificationTitle(
+ INFO_ALERT_TITLE,
+ this.name,
+ this.usersLimitDataset.freeUsersLimit,
+ ),
+ message: this.message(NOTIFICATION_LIMIT_MESSAGE, this.usersLimitDataset.freeUsersLimit),
+ },
[CLOSE_TO_LIMIT_VARIANT]: {
variant: 'warning',
title: this.title(WARNING_ALERT_TITLE, this.usersLimitDataset.remainingSeats),
@@ -42,6 +55,13 @@ export default {
},
},
methods: {
+ notificationTitle(titleTemplate, namespaceName, dashboardLimit) {
+ return sprintf(titleTemplate, {
+ namespaceName,
+ dashboardLimit,
+ });
+ },
+
title(titleTemplate, count) {
return sprintf(titleTemplate, {
count,
@@ -49,7 +69,14 @@ export default {
name: this.name,
});
},
+
+ message(messageTemplate, dashboardLimit) {
+ return sprintf(messageTemplate, {
+ dashboardLimit,
+ });
+ },
},
+ freeUserLimitHelpPath: helpPagePath('user/free_user_limit'),
};
</script>
@@ -60,6 +87,11 @@ export default {
:title="limitAttributes[limitVariant].title"
>
<gl-sprintf :message="limitAttributes[limitVariant].message">
+ <template #freeUserLimitLink="{ content }">
+ <gl-link :href="$options.freeUserLimitHelpPath" class="gl-label-link">{{
+ content
+ }}</gl-link>
+ </template>
<template #trialLink="{ content }">
<gl-link
:href="usersLimitDataset.newTrialRegistrationPath"
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index a894eb24d38..edc0ebff083 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -139,6 +139,9 @@ export const GROUP_MODAL_LABELS = {
export const LEARN_GITLAB = 'learn_gitlab';
export const ON_SHOW_TRACK_LABEL = 'over_limit_modal_viewed';
+export const INFO_ALERT_TITLE = s__(
+ 'InviteMembersModal|Your top-level group %{namespaceName} is over the %{dashboardLimit} user limit.',
+);
export const WARNING_ALERT_TITLE = s__(
'InviteMembersModal|You only have space for %{count} more %{members} in %{name}',
);
@@ -148,17 +151,22 @@ export const DANGER_ALERT_TITLE = s__(
export const REACHED_LIMIT_VARIANT = 'reached';
export const CLOSE_TO_LIMIT_VARIANT = 'close';
+export const NOTIFICATION_LIMIT_VARIANT = 'notification';
export const REACHED_LIMIT_MESSAGE = s__(
- 'InviteMembersModal|To invite new users to this namespace, you must remove existing users. You can still add existing namespace users.',
+ 'InviteMembersModal|To invite new users to this top-level group, you must remove existing users. You can still add existing users from the top-level group, including any subgroups and projects.',
);
export const REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE = REACHED_LIMIT_MESSAGE.concat(
s__(
- 'InviteMembersModal| To get more members, the owner of this namespace can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.',
+ 'InviteMembersModal| To get more members, the owner of this top-level group can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.',
),
);
export const CLOSE_TO_LIMIT_MESSAGE = s__(
'InviteMembersModal|To get more members an owner of the group can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.',
);
+
+export const NOTIFICATION_LIMIT_MESSAGE = s__(
+ 'InviteMembersModal|GitLab will enforce this limit in the future. If you are over %{dashboardLimit} users when enforcement begins, your top-level group will be placed in a %{freeUserLimitLinkStart}read-only state%{freeUserLimitLinkEnd}. To avoid being placed in a read-only state, reduce your top-level group to %{dashboardLimit} users or less, or purchase a paid tier.',
+);
diff --git a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
index 543dca0afe1..14325d6b64e 100644
--- a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
+++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
@@ -1,11 +1,16 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { mapGetters } from 'vuex';
-import { __ } from '~/locale';
+import { sprintf, __ } from '~/locale';
import { IssuableType, WorkspaceType } from '~/issues/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
+const NoteableTypeText = {
+ issue: __('issue'),
+ merge_request: __('merge request'),
+};
+
export default {
WorkspaceType,
IssuableType,
@@ -40,7 +45,9 @@ export default {
iconName: 'spam',
visible: this.hidden,
dataTestId: 'hidden',
- tooltip: __('This issue is hidden because its author has been banned'),
+ tooltip: sprintf(__('This %{issuable} is hidden because its author has been banned'), {
+ issuable: NoteableTypeText[this.getNoteableData.targetType],
+ }),
},
];
},
diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index fd55f05e955..c815c7aaba9 100644
--- a/app/assets/javascripts/issuable/components/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -135,7 +135,6 @@ export default {
<gl-link
:href="computedPath"
class="sortable-link gl-font-weight-normal"
- target="_blank"
@click="handleTitleClick"
>
{{ title }}
diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js
index e8ba99e0e9e..99a3f76ca76 100644
--- a/app/assets/javascripts/issuable/issuable_form.js
+++ b/app/assets/javascripts/issuable/issuable_form.js
@@ -47,13 +47,12 @@ function getFallbackKey() {
}
export default class IssuableForm {
- static addAutosave(map, id, $input, searchTerm, fallbackKey) {
- if ($input.length) {
- map.set(
- id,
- new Autosave($input, [document.location.pathname, searchTerm, id], `${fallbackKey}=${id}`),
- );
- }
+ static addAutosave(map, id, element, searchTerm, fallbackKey) {
+ if (!element) return;
+ map.set(
+ id,
+ new Autosave(element, [document.location.pathname, searchTerm, id], `${fallbackKey}=${id}`),
+ );
}
constructor(form) {
@@ -122,28 +121,28 @@ export default class IssuableForm {
IssuableForm.addAutosave(
autosaveMap,
'title',
- this.form.find('input[name*="[title]"]'),
+ this.form.find('input[name*="[title]"]').get(0),
this.searchTerm,
this.fallbackKey,
);
IssuableForm.addAutosave(
autosaveMap,
'description',
- this.form.find('textarea[name*="[description]"]'),
+ this.form.find('textarea[name*="[description]"]').get(0),
this.searchTerm,
this.fallbackKey,
);
IssuableForm.addAutosave(
autosaveMap,
'confidential',
- this.form.find('input:checkbox[name*="[confidential]"]'),
+ this.form.find('input:checkbox[name*="[confidential]"]').get(0),
this.searchTerm,
this.fallbackKey,
);
IssuableForm.addAutosave(
autosaveMap,
'due_date',
- this.form.find('input[name*="[due_date]"]'),
+ this.form.find('input[name*="[due_date]"]').get(0),
this.searchTerm,
this.fallbackKey,
);
diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
index b9d876ef72f..8edc9a08c9e 100644
--- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
+++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
@@ -30,20 +30,35 @@ import { __ } from '~/locale';
import {
TOKEN_TITLE_ASSIGNEE,
TOKEN_TITLE_AUTHOR,
+ TOKEN_TITLE_LABEL,
+ TOKEN_TITLE_MILESTONE,
+ TOKEN_TITLE_MY_REACTION,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
+ TOKEN_TYPE_LABEL,
+ TOKEN_TYPE_MILESTONE,
+ TOKEN_TYPE_MY_REACTION,
} 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 { AutocompleteCache } from '../utils';
const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue');
+const EmojiToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue');
+const LabelToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue');
+const MilestoneToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue');
export default {
i18n: {
calendarButtonText: __('Subscribe to calendar'),
closed: __('CLOSED'),
closedMoved: __('CLOSED (MOVED)'),
- emptyStateTitle: __('Please select at least one filter to see results'),
+ emptyStateWithFilterTitle: __('Sorry, your filter produced no results'),
+ emptyStateWithFilterDescription: __('To widen your search, change or remove filters above'),
+ emptyStateWithoutFilterTitle: __('Please select at least one filter to see results'),
errorFetchingIssues: __('An error occurred while loading issues'),
rssButtonText: __('Subscribe to RSS feed'),
searchInputPlaceholder: __('Search or filter results...'),
@@ -60,8 +75,12 @@ export default {
GlTooltip: GlTooltipDirective,
},
inject: [
+ 'autocompleteAwardEmojisPath',
'calendarPath',
- 'emptyStateSvgPath',
+ 'dashboardLabelsPath',
+ 'dashboardMilestonesPath',
+ 'emptyStateWithFilterSvgPath',
+ 'emptyStateWithoutFilterSvgPath',
'hasBlockedIssuesFeature',
'hasIssuableHealthStatusFeature',
'hasIssueWeightsFeature',
@@ -117,6 +136,9 @@ export default {
this.issuesError = this.$options.i18n.errorFetchingIssues;
Sentry.captureException(error);
},
+ skip() {
+ return !this.hasSearch;
+ },
debounce: 200,
},
},
@@ -124,6 +146,25 @@ export default {
apiFilterParams() {
return convertToApiParams(this.filterTokens);
},
+ emptyStateDescription() {
+ return this.hasSearch ? this.$options.i18n.emptyStateWithFilterDescription : undefined;
+ },
+ emptyStateSvgPath() {
+ return this.hasSearch
+ ? this.emptyStateWithFilterSvgPath
+ : this.emptyStateWithoutFilterSvgPath;
+ },
+ emptyStateTitle() {
+ return this.hasSearch
+ ? this.$options.i18n.emptyStateWithFilterTitle
+ : this.$options.i18n.emptyStateWithoutFilterTitle;
+ },
+ hasSearch() {
+ return Boolean(this.searchQuery || Object.keys(this.urlFilterParams).length);
+ },
+ renderedIssues() {
+ return this.hasSearch ? this.issues : [];
+ },
searchQuery() {
return convertToSearchQuery(this.filterTokens);
},
@@ -159,12 +200,46 @@ export default {
preloadedUsers,
recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-author',
},
+ {
+ type: TOKEN_TYPE_LABEL,
+ title: TOKEN_TITLE_LABEL,
+ icon: 'labels',
+ token: LabelToken,
+ fetchLabels: this.fetchLabels,
+ recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-label',
+ },
+ {
+ type: TOKEN_TYPE_MILESTONE,
+ title: TOKEN_TITLE_MILESTONE,
+ icon: 'clock',
+ token: MilestoneToken,
+ fetchMilestones: this.fetchMilestones,
+ recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-milestone',
+ shouldSkipSort: true,
+ },
];
+ if (this.isSignedIn) {
+ tokens.push({
+ type: TOKEN_TYPE_MY_REACTION,
+ title: TOKEN_TITLE_MY_REACTION,
+ icon: 'thumb-up',
+ token: EmojiToken,
+ unique: true,
+ fetchEmojis: this.fetchEmojis,
+ recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-my_reaction',
+ });
+ }
+
+ tokens.sort((a, b) => a.title.localeCompare(b.title));
+
return tokens;
},
showPaginationControls() {
- return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage);
+ return (
+ this.renderedIssues.length > 0 &&
+ (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage)
+ );
},
sortOptions() {
return getSortOptions({
@@ -185,7 +260,34 @@ export default {
};
},
},
+ created() {
+ this.autocompleteCache = new AutocompleteCache();
+ },
methods: {
+ fetchEmojis(search) {
+ return this.autocompleteCache.fetch({
+ url: this.autocompleteAwardEmojisPath,
+ cacheName: 'emojis',
+ searchProperty: 'name',
+ search,
+ });
+ },
+ fetchLabels(search) {
+ return this.autocompleteCache.fetch({
+ url: this.dashboardLabelsPath,
+ cacheName: 'labels',
+ searchProperty: 'title',
+ search,
+ });
+ },
+ fetchMilestones(search) {
+ return this.autocompleteCache.fetch({
+ url: this.dashboardMilestonesPath,
+ cacheName: 'milestones',
+ searchProperty: 'title',
+ search,
+ });
+ },
fetchUsers(search) {
return axios.get('/-/autocomplete/users.json', { params: { active: true, search } });
},
@@ -266,7 +368,7 @@ export default {
:has-scoped-labels-feature="hasScopedLabelsFeature"
:initial-filter-value="filterTokens"
:initial-sort-by="sortKey"
- :issuables="issues"
+ :issuables="renderedIssues"
:issuables-loading="$apollo.queries.issues.loading"
namespace="dashboard"
recent-searches-storage-key="issues"
@@ -307,7 +409,11 @@ export default {
</template>
<template #empty-state>
- <gl-empty-state :svg-path="emptyStateSvgPath" :title="$options.i18n.emptyStateTitle" />
+ <gl-empty-state
+ :description="emptyStateDescription"
+ :svg-path="emptyStateSvgPath"
+ :title="emptyStateTitle"
+ />
</template>
</issuable-list>
</template>
diff --git a/app/assets/javascripts/issues/dashboard/index.js b/app/assets/javascripts/issues/dashboard/index.js
index e3e5cc614cb..005ab5ce3b0 100644
--- a/app/assets/javascripts/issues/dashboard/index.js
+++ b/app/assets/javascripts/issues/dashboard/index.js
@@ -14,8 +14,12 @@ export function mountIssuesDashboardApp() {
Vue.use(VueApollo);
const {
+ autocompleteAwardEmojisPath,
calendarPath,
- emptyStateSvgPath,
+ dashboardLabelsPath,
+ dashboardMilestonesPath,
+ emptyStateWithFilterSvgPath,
+ emptyStateWithoutFilterSvgPath,
hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature,
hasIssueWeightsFeature,
@@ -33,8 +37,12 @@ export function mountIssuesDashboardApp() {
defaultClient: createDefaultClient(),
}),
provide: {
+ autocompleteAwardEmojisPath,
calendarPath,
- emptyStateSvgPath,
+ dashboardLabelsPath,
+ dashboardMilestonesPath,
+ emptyStateWithFilterSvgPath,
+ emptyStateWithoutFilterSvgPath,
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql
index 8ffcb456755..43b8804108c 100644
--- a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql
+++ b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql
@@ -7,8 +7,14 @@ query getDashboardIssues(
$search: String
$sort: IssueSort
$state: IssuableState
+ $assigneeId: String
$assigneeUsernames: [String!]
$authorUsername: String
+ $labelName: [String]
+ $milestoneTitle: [String]
+ $milestoneWildcardId: MilestoneWildcardId
+ $myReactionEmoji: String
+ $not: NegatedIssueFilterInput
$afterCursor: String
$beforeCursor: String
$firstPageSize: Int
@@ -18,8 +24,14 @@ query getDashboardIssues(
search: $search
sort: $sort
state: $state
+ assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ milestoneWildcardId: $milestoneWildcardId
+ myReactionEmoji: $myReactionEmoji
+ not: $not
after: $afterCursor
before: $beforeCursor
first: $firstPageSize
diff --git a/app/assets/javascripts/issues/dashboard/utils.js b/app/assets/javascripts/issues/dashboard/utils.js
new file mode 100644
index 00000000000..6fa95b38649
--- /dev/null
+++ b/app/assets/javascripts/issues/dashboard/utils.js
@@ -0,0 +1,23 @@
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { MAX_LIST_SIZE } from '~/issues/list/constants';
+import axios from '~/lib/utils/axios_utils';
+
+export class AutocompleteCache {
+ constructor() {
+ this.cache = {};
+ }
+
+ fetch({ url, cacheName, searchProperty, search }) {
+ if (this.cache[cacheName]) {
+ const data = search
+ ? fuzzaldrinPlus.filter(this.cache[cacheName], search, { key: searchProperty })
+ : this.cache[cacheName].slice(0, MAX_LIST_SIZE);
+ return Promise.resolve(data);
+ }
+
+ return axios.get(url).then(({ data }) => {
+ this.cache[cacheName] = data;
+ return data.slice(0, MAX_LIST_SIZE);
+ });
+ }
+}
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 12a83f06453..e4000184f41 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -352,6 +352,7 @@ export default {
title: TOKEN_TITLE_LABEL,
icon: 'labels',
token: LabelToken,
+ operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT,
fetchLabels: this.fetchLabels,
recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-label`,
},
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 49a953cad43..87184799d5f 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -159,7 +159,7 @@ export const TYPE_TOKEN_OBJECTIVE_OPTION = {
};
export const TYPE_TOKEN_KEY_RESULT_OPTION = {
- icon: 'issue-type-key-result',
+ icon: 'issue-type-keyresult',
title: 'key_result',
value: 'key_result',
};
@@ -247,6 +247,7 @@ export const filters = {
[API_PARAM]: {
[NORMAL_FILTER]: 'labelName',
[SPECIAL_FILTER]: 'labelName',
+ [ALTERNATIVE_FILTER]: 'labelNames',
},
[URL_PARAM]: {
[OPERATOR_IS]: {
@@ -257,6 +258,9 @@ export const filters = {
[OPERATOR_NOT]: {
[NORMAL_FILTER]: 'not[label_name][]',
},
+ [OPERATOR_OR]: {
+ [ALTERNATIVE_FILTER]: 'or[label_name][]',
+ },
},
},
[TOKEN_TYPE_TYPE]: {
@@ -360,14 +364,17 @@ export const filters = {
},
[TOKEN_TYPE_HEALTH]: {
[API_PARAM]: {
- [NORMAL_FILTER]: 'healthStatus',
- [SPECIAL_FILTER]: 'healthStatus',
+ [NORMAL_FILTER]: 'healthStatusFilter',
+ [SPECIAL_FILTER]: 'healthStatusFilter',
},
[URL_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'health_status',
[SPECIAL_FILTER]: 'health_status',
},
+ [OPERATOR_NOT]: {
+ [NORMAL_FILTER]: 'not[health_status]',
+ },
},
},
[TOKEN_TYPE_CONTACT]: {
diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js
index b566e08731c..bbd081843ca 100644
--- a/app/assets/javascripts/issues/list/utils.js
+++ b/app/assets/javascripts/issues/list/utils.js
@@ -13,6 +13,8 @@ import {
TOKEN_TYPE_MILESTONE,
TOKEN_TYPE_RELEASE,
TOKEN_TYPE_TYPE,
+ TOKEN_TYPE_HEALTH,
+ TOKEN_TYPE_LABEL,
} from '~/vue_shared/components/filtered_search_bar/constants';
import {
ALTERNATIVE_FILTER,
@@ -252,8 +254,9 @@ const isSpecialFilter = (type, data) => {
const getFilterType = ({ type, value: { data, operator } }) => {
const isUnionedAuthor = type === TOKEN_TYPE_AUTHOR && operator === OPERATOR_OR;
+ const isUnionedLabel = type === TOKEN_TYPE_LABEL && operator === OPERATOR_OR;
- if (isUnionedAuthor) {
+ if (isUnionedAuthor || isUnionedLabel) {
return ALTERNATIVE_FILTER;
}
if (isSpecialFilter(type, data)) {
@@ -267,8 +270,13 @@ const wildcardTokens = [TOKEN_TYPE_ITERATION, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_R
const isWildcardValue = (tokenType, value) =>
wildcardTokens.includes(tokenType) && specialFilterValues.includes(value);
+const isHealthStatusSpecialFilter = (tokenType, value) =>
+ tokenType === TOKEN_TYPE_HEALTH && specialFilterValues.includes(value);
+
const requiresUpperCaseValue = (tokenType, value) =>
- tokenType === TOKEN_TYPE_TYPE || isWildcardValue(tokenType, value);
+ tokenType === TOKEN_TYPE_TYPE ||
+ isWildcardValue(tokenType, value) ||
+ isHealthStatusSpecialFilter(tokenType, value);
const formatData = (token) => {
if (requiresUpperCaseValue(token.type, token.value.data)) {
diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue
index 983e2e6530e..56e360c75e3 100644
--- a/app/assets/javascripts/issues/show/components/header_actions.vue
+++ b/app/assets/javascripts/issues/show/components/header_actions.vue
@@ -19,6 +19,7 @@ import { visitUrl } from '~/lib/utils/url_utility';
import { s__, __, sprintf } from '~/locale';
import eventHub from '~/notes/event_hub';
import Tracking from '~/tracking';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql';
import updateIssueMutation from '../queries/update_issue.mutation.graphql';
import DeleteIssueModal from './delete_issue_modal.vue';
@@ -50,6 +51,7 @@ export default {
GlDropdownItem,
GlLink,
GlModal,
+ AbuseCategorySelector,
},
directives: {
GlModal: GlModalDirective,
@@ -93,13 +95,15 @@ export default {
projectPath: {
default: '',
},
- reportAbusePath: {
- default: '',
- },
submitAsSpamPath: {
default: '',
},
},
+ data() {
+ return {
+ isReportAbuseDrawerOpen: false,
+ };
+ },
computed: {
...mapState(['isToggleStateButtonLoading']),
...mapGetters(['openState', 'getBlockedByIssues']),
@@ -163,6 +167,9 @@ export default {
this.invokeUpdateIssueMutation();
},
+ toggleReportAbuseDrawer(isOpen) {
+ this.isReportAbuseDrawerOpen = isOpen;
+ },
invokeUpdateIssueMutation() {
this.toggleStateButtonLoading(true);
@@ -255,7 +262,7 @@ export default {
<gl-dropdown-item v-if="canPromoteToEpic" @click="promoteToEpic">
{{ __('Promote to epic') }}
</gl-dropdown-item>
- <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath">
+ <gl-dropdown-item v-if="!isIssueAuthor" @click="toggleReportAbuseDrawer(true)">
{{ $options.i18n.reportAbuse }}
</gl-dropdown-item>
<gl-dropdown-item
@@ -314,7 +321,7 @@ export default {
>
{{ __('Promote to epic') }}
</gl-dropdown-item>
- <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath">
+ <gl-dropdown-item v-if="!isIssueAuthor" @click="toggleReportAbuseDrawer(true)">
{{ $options.i18n.reportAbuse }}
</gl-dropdown-item>
<gl-dropdown-item
@@ -360,5 +367,10 @@ export default {
:modal-id="$options.deleteModalId"
:title="deleteButtonText"
/>
+
+ <abuse-category-selector
+ :show-drawer="isReportAbuseDrawerOpen"
+ @close-drawer="toggleReportAbuseDrawer(false)"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js
index 22db19610c1..2fdae538902 100644
--- a/app/assets/javascripts/issues/show/components/incidents/constants.js
+++ b/app/assets/javascripts/issues/show/components/incidents/constants.js
@@ -12,6 +12,9 @@ export const timelineFormI18n = Object.freeze({
'Incident|Something went wrong while creating the incident timeline event.',
),
areaPlaceholder: s__('Incident|Timeline text...'),
+ areaDefaultMessage: s__('Incident|Incident'),
+ selectTags: __('Select tags'),
+ tagsLabel: __('Event tag (optional)'),
save: __('Save'),
cancel: __('Cancel'),
delete: __('Delete'),
@@ -42,4 +45,14 @@ export const timelineItemI18n = Object.freeze({
timeUTC: __('%{time} UTC'),
});
+export const timelineEventTagsI18n = Object.freeze({
+ startTime: __('Start time'),
+ endTime: __('End time'),
+});
+
export const MAX_TEXT_LENGTH = 280;
+
+export const TIMELINE_EVENT_TAGS = Object.values(timelineEventTagsI18n).map((item) => ({
+ text: item,
+ value: item,
+}));
diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
index 6bb72e82778..81111d42b39 100644
--- a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
@@ -74,6 +74,7 @@ export default {
incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
note: eventDetails.note,
occurredAt: eventDetails.occurredAt,
+ timelineEventTagNames: eventDetails.timelineEventTags,
},
},
update: this.updateCache,
diff --git a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue
index 8cdd62ca9ef..4ef9b9c5a99 100644
--- a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue
@@ -40,7 +40,7 @@ export default {
:is-event-processed="editTimelineEventActive"
:previous-occurred-at="event.occurredAt"
:previous-note="event.note"
- show-delete
+ is-editing
@save-event="saveEvent"
@cancel="$emit('hide-edit')"
@delete="$emit('delete')"
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 f1a3aebc990..6648e20865d 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
@@ -1,7 +1,9 @@
<script>
-import { GlDatepicker, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui';
+import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlListbox } from '@gitlab/ui';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
-import { MAX_TEXT_LENGTH, timelineFormI18n } from './constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { __, sprintf } from '~/locale';
+import { MAX_TEXT_LENGTH, TIMELINE_EVENT_TAGS, timelineFormI18n } from './constants';
import { getUtcShiftedDate } from './utils';
export default {
@@ -23,7 +25,9 @@ export default {
GlFormInput,
GlFormGroup,
GlButton,
+ GlListbox,
},
+ mixins: [glFeatureFlagsMixin()],
i18n: timelineFormI18n,
MAX_TEXT_LENGTH,
props: {
@@ -32,7 +36,7 @@ export default {
required: false,
default: false,
},
- showDelete: {
+ isEditing: {
type: Boolean,
required: false,
default: false,
@@ -51,6 +55,16 @@ export default {
required: false,
default: '',
},
+ previousTags: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ tags: {
+ type: Array,
+ required: false,
+ default: () => TIMELINE_EVENT_TAGS,
+ },
},
data() {
// if occurredAt is null, returns "now" in UTC
@@ -58,10 +72,12 @@ export default {
return {
timelineText: this.previousNote,
+ timelineTextIsDirty: this.isEditing,
placeholderDate,
hourPickerInput: placeholderDate.getHours(),
minutePickerInput: placeholderDate.getMinutes(),
datePickerInput: placeholderDate,
+ selectedTags: [...this.previousTags],
};
},
computed: {
@@ -85,6 +101,20 @@ export default {
timelineTextCount() {
return this.timelineText.length;
},
+ dropdownText() {
+ if (!this.selectedTags.length) {
+ return timelineFormI18n.selectTags;
+ }
+
+ const dropdownText =
+ this.selectedTags.length === 1
+ ? this.selectedTags[0]
+ : sprintf(__('%{numberOfSelectedTags} tags'), {
+ numberOfSelectedTags: this.selectedTags.length,
+ });
+
+ return dropdownText;
+ },
},
mounted() {
this.focusDate();
@@ -96,14 +126,35 @@ export default {
this.hourPickerInput = newPlaceholderDate.getHours();
this.minutePickerInput = newPlaceholderDate.getMinutes();
this.timelineText = '';
+ this.selectedTags = [];
},
focusDate() {
this.$refs.datepicker.$el.querySelector('input')?.focus();
},
+ setTimelineTextDirty() {
+ this.timelineTextIsDirty = true;
+ },
+ onTagsChange(tagValue) {
+ this.selectedTags = [...tagValue];
+
+ if (!this.timelineTextIsDirty) {
+ this.timelineText = this.generateTimelineTextFromTags(this.selectedTags);
+ }
+ },
+ generateTimelineTextFromTags(tags) {
+ if (!tags.length) {
+ return '';
+ }
+
+ const tagsMessage = tags.map((tag) => tag.toLocaleLowerCase()).join(', ');
+
+ return `${timelineFormI18n.areaDefaultMessage} ${tagsMessage}`;
+ },
handleSave(addAnotherEvent) {
const event = {
note: this.timelineText,
occurredAt: this.occurredAtString,
+ timelineEventTags: this.selectedTags,
};
this.$emit('save-event', event, addAnotherEvent);
},
@@ -146,6 +197,16 @@ export default {
<p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p>
</div>
</div>
+ <gl-form-group v-if="glFeatures.incidentEventTags" :label="$options.i18n.tagsLabel">
+ <gl-listbox
+ :selected="selectedTags"
+ :toggle-text="dropdownText"
+ :items="tags"
+ :is-check-centered="true"
+ :multiple="true"
+ @select="onTagsChange"
+ />
+ </gl-form-group>
<div class="common-note-form">
<gl-form-group class="gl-mb-3" :label="$options.i18n.areaLabel">
<markdown-field
@@ -169,6 +230,7 @@ export default {
aria-describedby="timeline-form-hint"
:placeholder="$options.i18n.areaPlaceholder"
:maxlength="$options.MAX_TEXT_LENGTH"
+ @input="setTimelineTextDirty"
>
</textarea>
<div id="timeline-form-hint" class="gl-sr-only">{{ $options.i18n.hint }}</div>
@@ -214,7 +276,7 @@ export default {
{{ $options.i18n.cancel }}
</gl-button>
<gl-button
- v-if="showDelete"
+ v-if="isEditing"
class="gl-ml-auto btn-danger"
:disabled="isEventProcessed"
@click="$emit('delete')"
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index 3cb5007ab0d..21d877c5fe6 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -83,7 +83,7 @@ export function initIssueApp(issueData, store) {
return undefined;
}
- const { fullPath } = el.dataset;
+ const { fullPath, registerPath, signInPath } = el.dataset;
scrollToTargetOnResize();
@@ -99,6 +99,8 @@ export function initIssueApp(issueData, store) {
provide: {
canCreateIncident,
fullPath,
+ registerPath,
+ signInPath,
hasIssueWeightsFeature,
},
computed: {
@@ -150,6 +152,8 @@ export function initHeaderActions(store, type = '') {
projectPath: el.dataset.projectPath,
projectId: el.dataset.projectId,
reportAbusePath: el.dataset.reportAbusePath,
+ reportedUserId: el.dataset.reportedUserId,
+ reportedFromUrl: el.dataset.reportedFromUrl,
submitAsSpamPath: el.dataset.submitAsSpamPath,
},
render: (createElement) => createElement(HeaderActions),
diff --git a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue
index 9b36642feb7..dd9afb01590 100644
--- a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue
+++ b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue
@@ -1,11 +1,6 @@
<script>
-import {
- GlDropdown,
- GlSearchBoxByType,
- GlLoadingIcon,
- GlDropdownItem,
- GlAvatarLabeled,
-} from '@gitlab/ui';
+import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui';
+import { debounce } from 'lodash';
import { __ } from '~/locale';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import { PROJECTS_PER_PAGE } from '../constants';
@@ -17,11 +12,8 @@ export default {
endCursor: '',
},
components: {
- GlDropdown,
- GlDropdownItem,
- GlSearchBoxByType,
- GlLoadingIcon,
GlAvatarLabeled,
+ GlCollapsibleListbox,
},
props: {
selectedProject: {
@@ -34,6 +26,7 @@ export default {
return {
initialProjectsLoading: true,
projectSearchQuery: '',
+ selectedProjectId: this.selectedProject?.id,
};
},
apollo: {
@@ -66,17 +59,27 @@ export default {
projectDropdownText() {
return this.selectedProject?.nameWithNamespace || this.$options.i18n.selectProjectText;
},
+ projectList() {
+ return (this.projects || []).map((project) => ({
+ ...project,
+ text: project.nameWithNamespace,
+ value: String(project.id),
+ }));
+ },
},
methods: {
- onProjectSelect(project) {
- this.$emit('change', project);
+ findProjectById(id) {
+ return this.projects.find((project) => id === project.id);
+ },
+ onProjectSelect(projectId) {
+ this.$emit('change', this.findProjectById(projectId));
},
onError({ message } = {}) {
this.$emit('error', { message });
},
- isProjectSelected(project) {
- return project.id === this.selectedProject?.id;
- },
+ onSearch: debounce(function debouncedSearch(query) {
+ this.projectSearchQuery = query;
+ }, 250),
},
i18n: {
selectProjectText: __('Select a project'),
@@ -86,37 +89,29 @@ export default {
</script>
<template>
- <gl-dropdown
- :text="projectDropdownText"
- :loading="initialProjectsLoading"
- menu-class="gl-w-auto!"
+ <gl-collapsible-listbox
+ v-model="selectedProjectId"
+ data-testid="project-select"
+ :items="projectList"
+ :toggle-text="projectDropdownText"
:header-text="$options.i18n.selectProjectText"
+ :loading="initialProjectsLoading"
+ :searchable="true"
+ :searching="projectsLoading"
+ @search="onSearch"
+ @select="onProjectSelect"
>
- <template #header>
- <gl-search-box-by-type v-model.trim="projectSearchQuery" :debounce="250" />
- </template>
-
- <gl-loading-icon v-show="projectsLoading" />
- <template v-if="!projectsLoading">
- <gl-dropdown-item
- v-for="project in projects"
- :key="project.id"
- is-check-item
- is-check-centered
- :is-checked="isProjectSelected(project)"
- :data-testid="`test-project-${project.id}`"
- @click="onProjectSelect(project)"
- >
- <gl-avatar-labeled
- class="gl-text-truncate"
- :shape="$options.AVATAR_SHAPE_OPTION_RECT"
- :size="32"
- :src="project.avatarUrl"
- :label="project.name"
- :entity-name="project.name"
- :sub-label="project.nameWithNamespace"
- />
- </gl-dropdown-item>
+ <template #list-item="{ item: project }">
+ <gl-avatar-labeled
+ v-if="project"
+ class="gl-text-truncate"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ :size="32"
+ :src="project.avatarUrl"
+ :label="project.name"
+ :entity-name="project.name"
+ :sub-label="project.nameWithNamespace"
+ />
</template>
- </gl-dropdown>
+ </gl-collapsible-listbox>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
index 22a6c0751f4..44575455a34 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
@@ -10,7 +10,6 @@ import { SET_ALERT } from '../store/mutation_types';
import SignInPage from '../pages/sign_in/sign_in_page.vue';
import SubscriptionsPage from '../pages/subscriptions_page.vue';
import UserLink from './user_link.vue';
-import CompatibilityAlert from './compatibility_alert.vue';
import BrowserSupportAlert from './browser_support_alert.vue';
export default {
@@ -19,11 +18,10 @@ export default {
GlAlert,
GlLink,
GlSprintf,
- UserLink,
- CompatibilityAlert,
BrowserSupportAlert,
SignInPage,
SubscriptionsPage,
+ UserLink,
},
mixins: [glFeatureFlagMixin()],
inject: {
@@ -123,8 +121,6 @@ export default {
<main class="jira-connect-app gl-px-5 gl-pt-7 gl-mx-auto">
<browser-support-alert v-if="!isBrowserSupported" class="gl-mb-7" />
<div v-else data-testid="jira-connect-app">
- <compatibility-alert class="gl-mb-7" />
-
<gl-alert
v-if="shouldShowAlert"
:variant="alert.variant"
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue
deleted file mode 100644
index 9b50681515e..00000000000
--- a/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue
+++ /dev/null
@@ -1,73 +0,0 @@
-<script>
-import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
-import { s__ } from '~/locale';
-import { helpPagePath } from '~/helpers/help_page_helper';
-import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-
-const COMPATIBILITY_ALERT_STATE_KEY = 'compatibility_alert_dismissed';
-
-export default {
- name: 'CompatibilityAlert',
- components: {
- GlAlert,
- GlSprintf,
- GlLink,
- LocalStorageSync,
- },
- mixins: [glFeatureFlagMixin()],
- data() {
- return {
- alertDismissed: false,
- };
- },
- computed: {
- shouldShowAlert() {
- return !this.alertDismissed;
- },
- isOauthSelfManagedEnabled() {
- return this.glFeatures.jiraConnectOauth && this.glFeatures.jiraConnectOauthSelfManaged;
- },
- alertBody() {
- return this.isOauthSelfManagedEnabled
- ? this.$options.i18n.body
- : this.$options.i18n.bodyDotCom;
- },
- },
- methods: {
- dismissAlert() {
- this.alertDismissed = true;
- },
- },
- i18n: {
- title: s__('Integrations|Known limitations'),
- body: s__(
- 'Integrations|Adding a namespace only works in browsers that allow cross-site cookies. %{linkStart}Learn more%{linkEnd}.',
- ),
- bodyDotCom: s__(
- 'Integrations|This integration only works with GitLab.com. Adding a namespace only works in browsers that allow cross-site cookies. %{linkStart}Learn more%{linkEnd}.',
- ),
- },
- DOCS_LINK_URL: helpPagePath('integration/jira/connect-app'),
- COMPATIBILITY_ALERT_STATE_KEY,
-};
-</script>
-<template>
- <local-storage-sync
- v-model="alertDismissed"
- :storage-key="$options.COMPATIBILITY_ALERT_STATE_KEY"
- >
- <gl-alert
- v-if="shouldShowAlert"
- variant="info"
- :title="$options.i18n.title"
- @dismiss="dismissAlert"
- >
- <gl-sprintf :message="alertBody">
- <template #link="{ content }">
- <gl-link :href="$options.DOCS_LINK_URL" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </gl-alert>
- </local-storage-sync>
-</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
index 4cf3a1a0279..65c69bcfa82 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
@@ -4,6 +4,7 @@ import { GlButton } from '@gitlab/ui';
import { sprintf } from '~/locale';
import {
+ GITLAB_COM_BASE_PATH,
I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
I18N_CUSTOM_SIGN_IN_BUTTON_TEXT,
I18N_OAUTH_APPLICATION_ID_ERROR_MESSAGE,
@@ -40,8 +41,11 @@ export default {
};
},
computed: {
+ isGitlabCom() {
+ return this.gitlabBasePath === GITLAB_COM_BASE_PATH;
+ },
buttonText() {
- if (!this.gitlabBasePath) {
+ if (this.isGitlabCom) {
return I18N_DEFAULT_SIGN_IN_BUTTON_TEXT;
}
@@ -71,9 +75,9 @@ export default {
this.codeVerifier = createCodeVerifier();
const codeChallenge = await createCodeChallenge(this.codeVerifier);
try {
- this.clientId = this.gitlabBasePath
- ? await this.fetchOauthClientId()
- : this.oauthMetadata?.oauth_token_payload?.client_id;
+ this.clientId = this.isGitlabCom
+ ? this.oauthMetadata?.oauth_token_payload?.client_id
+ : await this.fetchOauthClientId();
} catch {
throw new Error(I18N_OAUTH_APPLICATION_ID_ERROR_MESSAGE);
}
@@ -92,7 +96,7 @@ export default {
);
// Rebase URL on the specified GitLab base path (if specified).
- if (this.gitlabBasePath) {
+ if (!this.isGitlabCom) {
const gitlabBasePathURL = new URL(this.gitlabBasePath);
oauthAuthorizeURLWithChallenge.hostname = gitlabBasePathURL.hostname;
oauthAuthorizeURLWithChallenge.pathname = `${
@@ -118,7 +122,7 @@ export default {
this.setAlert({
linkUrl: OAUTH_SELF_MANAGED_DOC_LINK,
title: I18N_OAUTH_FAILED_TITLE,
- message: this.gitlabBasePath ? I18N_OAUTH_FAILED_MESSAGE : '',
+ message: this.isGitlabCom ? '' : I18N_OAUTH_FAILED_MESSAGE,
variant: 'danger',
});
}
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue
index 91b66c87694..782e8a625a9 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue
@@ -1,6 +1,7 @@
<script>
import { s__ } from '~/locale';
+import { GITLAB_COM_BASE_PATH } from '~/jira_connect/subscriptions/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SubscriptionsList from '../../components/subscriptions_list.vue';
@@ -28,6 +29,7 @@ export default {
signInButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'),
signInText: s__('JiraService|Sign in to GitLab.com to get started.'),
},
+ GITLAB_COM_BASE_PATH,
methods: {
onSignInError() {
this.$emit('error');
@@ -43,6 +45,7 @@ export default {
<div class="gl-display-flex gl-justify-content-end gl-mb-3">
<sign-in-oauth-button
v-if="useSignInOauthButton"
+ :gitlab-base-path="$options.GITLAB_COM_BASE_PATH"
@sign-in="$emit('sign-in-oauth', $event)"
@error="onSignInError"
>
@@ -59,6 +62,7 @@ export default {
<p class="gl-mb-7">{{ $options.i18n.signInText }}</p>
<sign-in-oauth-button
v-if="useSignInOauthButton"
+ :gitlab-base-path="$options.GITLAB_COM_BASE_PATH"
@sign-in="$emit('sign-in-oauth', $event)"
@error="onSignInError"
/>
diff --git a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
index d7bbd6daed2..734d3ca0d49 100644
--- a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
+++ b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
@@ -68,6 +68,7 @@ export default {
required: true,
},
},
+ clearBtnSharedClasses: ['gl-flex-grow-0 gl-flex-basis-0'],
inputTypes: {
key: 'key',
value: 'value',
@@ -229,16 +230,23 @@ export default {
v-gl-tooltip
:aria-label="$options.i18n.clearInputs"
:title="$options.i18n.clearInputs"
- class="gl-flex-grow-0 gl-flex-basis-0"
+ :class="$options.clearBtnSharedClasses"
category="tertiary"
variant="danger"
icon="clear"
data-testid="delete-variable-btn"
@click="deleteVariable(variable.id)"
/>
-
- <!-- delete variable button placeholder to not break flex layout -->
- <div v-else class="gl-w-7 gl-mr-3" data-testid="delete-variable-btn-placeholder"></div>
+ <!-- Placeholder button to keep the layout fixed -->
+ <gl-button
+ v-else
+ class="gl-opacity-0 gl-pointer-events-none"
+ :class="$options.clearBtnSharedClasses"
+ data-testid="delete-variable-btn-placeholder"
+ category="tertiary"
+ variant="danger"
+ icon="clear"
+ />
</div>
<div class="gl-text-center gl-mt-5">
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue
index d8c5c292f52..9ee4439b618 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue
@@ -1,7 +1,7 @@
<script>
import { GlTable } from '@gitlab/ui';
import { s__ } from '~/locale';
-import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
+import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import ActionsCell from './cells/actions_cell.vue';
import DurationCell from './cells/duration_cell.vue';
import JobCell from './cells/job_cell.vue';
@@ -14,7 +14,7 @@ export default {
},
components: {
ActionsCell,
- CiBadge,
+ CiBadgeLink,
DurationCell,
GlTable,
JobCell,
@@ -55,7 +55,7 @@ export default {
</template>
<template #cell(status)="{ item }">
- <ci-badge :status="item.detailedStatus" />
+ <ci-badge-link :status="item.detailedStatus" />
</template>
<template #cell(job)="{ item }">
diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js
index 272181f830c..a81edb240ad 100644
--- a/app/assets/javascripts/jobs/store/actions.js
+++ b/app/assets/javascripts/jobs/store/actions.js
@@ -2,7 +2,7 @@ import Visibility from 'visibilityjs';
import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { setFaviconOverlay, resetFavicon } from '~/lib/utils/favicon';
-import httpStatusCodes from '~/lib/utils/http_status';
+import { HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status';
import Poll from '~/lib/utils/poll';
import {
canScroll,
@@ -178,7 +178,7 @@ export const fetchJobLog = ({ dispatch, state }) =>
}
})
.catch((e) => {
- if (e.response.status === httpStatusCodes.FORBIDDEN) {
+ if (e.response.status === HTTP_STATUS_FORBIDDEN) {
dispatch('receiveJobLogUnauthorizedError');
} else {
reportToSentry('job_actions', e);
diff --git a/app/assets/javascripts/language_switcher/components/app.vue b/app/assets/javascripts/language_switcher/components/app.vue
index 71babe6c614..4d3fe22e247 100644
--- a/app/assets/javascripts/language_switcher/components/app.vue
+++ b/app/assets/javascripts/language_switcher/components/app.vue
@@ -1,11 +1,17 @@
<script>
-import { GlCollapsibleListbox } from '@gitlab/ui';
+import { GlCollapsibleListbox, GlLink } from '@gitlab/ui';
+import { __ } from '~/locale';
import { setCookie } from '~/lib/utils/common_utils';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { PREFERRED_LANGUAGE_COOKIE_KEY } from '../constants';
+const HELP_TRANSLATE_MSG = __('Help translate to your language');
+const HELP_TRANSLATE_HREF = helpPagePath('/development/i18n/translation.md');
+
export default {
components: {
GlCollapsibleListbox,
+ GlLink,
},
inject: {
locales: {
@@ -25,7 +31,12 @@ export default {
setCookie(PREFERRED_LANGUAGE_COOKIE_KEY, code);
window.location.reload();
},
+ itemTestSelector(locale) {
+ return `language_switcher_lang_${locale}`;
+ },
},
+ HELP_TRANSLATE_MSG,
+ HELP_TRANSLATE_HREF,
};
</script>
<template>
@@ -41,9 +52,20 @@ export default {
@select="onLanguageSelected"
>
<template #list-item="{ item: locale }">
- <span :data-testid="`language_switcher_lang_${locale.value}`">
+ <span
+ :data-testid="itemTestSelector(locale.value)"
+ :data-qa-selector="itemTestSelector(locale.value)"
+ >
{{ locale.text }}
</span>
</template>
+ <template #footer>
+ <div
+ class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-display-flex gl-justify-content-center gl-p-3"
+ data-testid="footer"
+ >
+ <gl-link :href="$options.HELP_TRANSLATE_HREF">{{ $options.HELP_TRANSLATE_MSG }}</gl-link>
+ </div>
+ </template>
</gl-collapsible-listbox>
</template>
diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js
index ab83f1ecc14..90c1b31286a 100644
--- a/app/assets/javascripts/layout_nav.js
+++ b/app/assets/javascripts/layout_nav.js
@@ -12,8 +12,45 @@ function hideEndFade($scrollingTabs) {
});
}
+export function initScrollingTabs() {
+ const $scrollingTabs = $('.scrolling-tabs').not('.is-initialized');
+ $scrollingTabs.addClass('is-initialized');
+
+ $(window)
+ .on('resize.nav', () => {
+ hideEndFade($scrollingTabs);
+ })
+ .trigger('resize.nav');
+
+ $scrollingTabs.on('scroll', function tabsScrollEvent() {
+ const $this = $(this);
+ const currentPosition = $this.scrollLeft();
+ const maxPosition = $this.prop('scrollWidth') - $this.outerWidth();
+
+ $this.siblings('.fade-left').toggleClass('scrolling', currentPosition > 0);
+ $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1);
+ });
+
+ $scrollingTabs.each(function scrollTabsEachLoop() {
+ const $this = $(this);
+ const scrollingTabWidth = $this.width();
+ const $active = $this.find('.active');
+ const activeWidth = $active.width();
+
+ if ($active.length) {
+ const offset = $active.offset().left + activeWidth;
+
+ if (offset > scrollingTabWidth - 30) {
+ const scrollLeft = offset - scrollingTabWidth / 2 - activeWidth / 2;
+
+ $this.scrollLeft(scrollLeft);
+ }
+ }
+ });
+}
+
function initDeferred() {
- $(document).trigger('init.scrolling-tabs');
+ initScrollingTabs();
const appEl = document.getElementById('whats-new-app');
if (!appEl) return;
@@ -34,43 +71,5 @@ export default function initLayoutNav() {
initFlyOutNav();
- // We need to init it on DomContentLoaded as others could also call it
- $(document).on('init.scrolling-tabs', () => {
- const $scrollingTabs = $('.scrolling-tabs').not('.is-initialized');
- $scrollingTabs.addClass('is-initialized');
-
- $(window)
- .on('resize.nav', () => {
- hideEndFade($scrollingTabs);
- })
- .trigger('resize.nav');
-
- $scrollingTabs.on('scroll', function tabsScrollEvent() {
- const $this = $(this);
- const currentPosition = $this.scrollLeft();
- const maxPosition = $this.prop('scrollWidth') - $this.outerWidth();
-
- $this.siblings('.fade-left').toggleClass('scrolling', currentPosition > 0);
- $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1);
- });
-
- $scrollingTabs.each(function scrollTabsEachLoop() {
- const $this = $(this);
- const scrollingTabWidth = $this.width();
- const $active = $this.find('.active');
- const activeWidth = $active.width();
-
- if ($active.length) {
- const offset = $active.offset().left + activeWidth;
-
- if (offset > scrollingTabWidth - 30) {
- const scrollLeft = offset - scrollingTabWidth / 2 - activeWidth / 2;
-
- $this.scrollLeft(scrollLeft);
- }
- }
- });
- });
-
requestIdleCallback(initDeferred);
}
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 4ce63d518a6..241488c8039 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -142,7 +142,7 @@ export const getOuterHeight = (selector) => {
const element = document.querySelector(selector);
if (!element) {
- return undefined;
+ return 0;
}
return element.offsetHeight;
@@ -154,6 +154,11 @@ export const contentTop = () => {
() => getOuterHeight('#js-peek'),
() => getOuterHeight('.navbar-gitlab'),
({ desktop }) => {
+ const mrStickyHeader = document.querySelector('.merge-request-sticky-header');
+ if (mrStickyHeader) {
+ return mrStickyHeader.offsetHeight;
+ }
+
const container = document.querySelector('.discussions-counter');
let size = 0;
@@ -161,11 +166,12 @@ export const contentTop = () => {
size = container.offsetHeight;
}
+ size += getOuterHeight('.merge-request-tabs');
+ size += getOuterHeight('.issue-sticky-header.gl-fixed');
+
return size;
},
- () => getOuterHeight('.merge-request-sticky-header, .merge-request-tabs'),
() => getOuterHeight('.js-diff-files-changed'),
- () => getOuterHeight('.issue-sticky-header.gl-fixed'),
({ desktop }) => {
const diffsTabIsActive = window.mrTabs?.currentAction === 'diffs';
let size;
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 4e0a59d0a38..9eb812b8694 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
@@ -531,7 +531,7 @@ export const getOverlappingDaysInPeriods = (givenPeriodLeft = {}, givenPeriodRig
/**
* Mimics the behaviour of the rails distance_of_time_in_words function
- * https://api.rubyonrails.org/v6.0.1/classes/ActionView/Helpers/DateHelper.html#method-i-distance_of_time_in_words
+ * https://api.rubyonrails.org/classes/ActionView/Helpers/DateHelper.html#method-i-distance_of_time_in_words
* 0 < -> 29 secs => less than a minute
* 30 secs < -> 1 min, 29 secs => 1 minute
* 1 min, 30 secs < -> 44 mins, 29 secs => [2..44] minutes
diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
index 737c18d1bce..04a82836f69 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
@@ -414,21 +414,21 @@ export const durationTimeFormatted = (duration) => {
*
* @param {Number} offset UTC offset in seconds as a integer
*
- * @return {String} the + or - offset in hours, e.g. `- 10`, `0`, `+ 4`
+ * @return {String} the + or - offset in hours, e.g. `-10`, ` 0`, `+4`
*/
export const formatUtcOffset = (offset) => {
const parsed = parseInt(offset, 10);
if (Number.isNaN(parsed) || parsed === 0) {
- return `0`;
+ return ` 0`;
}
const prefix = offset > 0 ? '+' : '-';
- return `${prefix} ${Math.abs(offset / 3600)}`;
+ return `${prefix}${Math.abs(offset / 3600)}`;
};
/**
* Returns formatted timezone
*
* @param {Object} timezone item with offset and name
- * @returns {String} the UTC timezone with the offset, e.g. `[UTC + 2] Berlin`
+ * @returns {String} the UTC timezone with the offset, e.g. `[UTC+2] Berlin, [UTC 0] London`
*/
-export const formatTimezone = ({ offset, name }) => `[UTC ${formatUtcOffset(offset)}] ${name}`;
+export const formatTimezone = ({ offset, name }) => `[UTC${formatUtcOffset(offset)}] ${name}`;
diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js
index ec0d8d433a5..678ebc35565 100644
--- a/app/assets/javascripts/lib/utils/http_status.js
+++ b/app/assets/javascripts/lib/utils/http_status.js
@@ -1,4 +1,5 @@
export const HTTP_STATUS_ABORTED = 0;
+export const HTTP_STATUS_OK = 200;
export const HTTP_STATUS_CREATED = 201;
export const HTTP_STATUS_ACCEPTED = 202;
export const HTTP_STATUS_NON_AUTHORITATIVE_INFORMATION = 203;
@@ -14,21 +15,15 @@ export const HTTP_STATUS_GONE = 410;
export const HTTP_STATUS_PAYLOAD_TOO_LARGE = 413;
export const HTTP_STATUS_UNPROCESSABLE_ENTITY = 422;
export const HTTP_STATUS_TOO_MANY_REQUESTS = 429;
-
-// TODO move the rest of the status codes to primitive constants
-// https://docs.gitlab.com/ee/development/fe_guide/style/javascript.html#export-constants-as-primitives
-const httpStatusCodes = {
- OK: 200,
- BAD_REQUEST: 400,
- UNAUTHORIZED: 401,
- FORBIDDEN: 403,
- NOT_FOUND: 404,
- INTERNAL_SERVER_ERROR: 500,
- SERVICE_UNAVAILABLE: 503,
-};
+export const HTTP_STATUS_BAD_REQUEST = 400;
+export const HTTP_STATUS_UNAUTHORIZED = 401;
+export const HTTP_STATUS_FORBIDDEN = 403;
+export const HTTP_STATUS_NOT_FOUND = 404;
+export const HTTP_STATUS_INTERNAL_SERVER_ERROR = 500;
+export const HTTP_STATUS_SERVICE_UNAVAILABLE = 503;
export const successCodes = [
- httpStatusCodes.OK,
+ HTTP_STATUS_OK,
HTTP_STATUS_CREATED,
HTTP_STATUS_ACCEPTED,
HTTP_STATUS_NON_AUTHORITATIVE_INFORMATION,
@@ -39,5 +34,3 @@ export const successCodes = [
HTTP_STATUS_ALREADY_REPORTED,
HTTP_STATUS_IM_USED,
];
-
-export default httpStatusCodes;
diff --git a/app/assets/javascripts/lib/utils/poll_until_complete.js b/app/assets/javascripts/lib/utils/poll_until_complete.js
index 3545db3a227..dbe54dceb52 100644
--- a/app/assets/javascripts/lib/utils/poll_until_complete.js
+++ b/app/assets/javascripts/lib/utils/poll_until_complete.js
@@ -1,5 +1,5 @@
import axios from '~/lib/utils/axios_utils';
-import httpStatusCodes from './http_status';
+import { HTTP_STATUS_OK } from './http_status';
import Poll from './poll';
/**
@@ -30,7 +30,7 @@ export default (url, config = {}) =>
data: { url, config },
method: 'axiosGet',
successCallback: (response) => {
- if (response.status === httpStatusCodes.OK) {
+ if (response.status === HTTP_STATUS_OK) {
resolve(response);
eTagPoll.stop();
}
diff --git a/app/assets/javascripts/locale/ensure_single_line.js b/app/assets/javascripts/locale/ensure_single_line.cjs
index c2c63777001..c2c63777001 100644
--- a/app/assets/javascripts/locale/ensure_single_line.js
+++ b/app/assets/javascripts/locale/ensure_single_line.cjs
diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js
index ad01da2eb17..c1afabf1e35 100644
--- a/app/assets/javascripts/locale/index.js
+++ b/app/assets/javascripts/locale/index.js
@@ -1,5 +1,5 @@
import Jed from 'jed';
-import ensureSingleLine from './ensure_single_line';
+import ensureSingleLine from './ensure_single_line.cjs';
import sprintf from './sprintf';
const GITLAB_FALLBACK_LANGUAGE = 'en';
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index df3b55ed2ad..fd5c4abe729 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -30,6 +30,7 @@ import initLogoAnimation from './logo';
import initBreadcrumbs from './breadcrumb';
import initPersistentUserCallouts from './persistent_user_callouts';
import { initUserTracking, initDefaultTrackers } from './tracking';
+import { initSidebarTracking } from './pages/shared/nav/sidebar_tracking';
import initServicePingConsent from './service_ping_consent';
import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
@@ -99,10 +100,19 @@ function deferredInitialisation() {
initBroadcastNotifications();
initPersistentUserCallouts();
initDefaultTrackers();
+ initSidebarTracking();
initFeatureHighlight();
initCopyCodeButton();
initGitlabVersionCheck();
+ // Init super sidebar
+ if (gon.use_new_navigation) {
+ // eslint-disable-next-line promise/catch-or-return
+ import('./super_sidebar/super_sidebar_bundle').then(({ initSuperSidebar }) => {
+ initSuperSidebar();
+ });
+ }
+
addSelectOnFocusBehaviour('.js-select-on-focus');
const glTooltipDelay = localStorage.getItem('gl-tooltip-delay');
diff --git a/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue
index f4893721b9e..164fed308ff 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
@@ -49,8 +49,6 @@ export default {
:message="message"
:title="s__('Member|Deny access')"
:is-access-request="true"
- icon="close"
- button-category="primary"
/>
</div>
</action-button-group>
diff --git a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
index 112f722c632..90034f46e7c 100644
--- a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
@@ -40,7 +40,6 @@ export default {
:title="$options.title"
:aria-label="$options.title"
icon="check"
- variant="confirm"
type="submit"
/>
</gl-form>
diff --git a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
index ab9abfd38c6..91062c222f4 100644
--- a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
@@ -41,8 +41,6 @@ export default {
<remove-member-button
:member-id="member.id"
:message="message"
- icon="remove"
- button-category="primary"
:title="s__('Member|Revoke invite')"
is-invite
/>
diff --git a/app/assets/javascripts/members/components/action_buttons/leave_button.vue b/app/assets/javascripts/members/components/action_buttons/leave_button.vue
deleted file mode 100644
index f600a207b8d..00000000000
--- a/app/assets/javascripts/members/components/action_buttons/leave_button.vue
+++ /dev/null
@@ -1,40 +0,0 @@
-<script>
-import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
-import { LEAVE_MODAL_ID } from '../../constants';
-import LeaveModal from '../modals/leave_modal.vue';
-
-export default {
- name: 'LeaveButton',
- title: __('Leave'),
- modalId: LEAVE_MODAL_ID,
- components: {
- GlButton,
- LeaveModal,
- },
- directives: {
- GlModal: GlModalDirective,
- GlTooltip: GlTooltipDirective,
- },
- props: {
- member: {
- type: Object,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <div>
- <gl-button
- v-gl-tooltip.hover
- v-gl-modal="$options.modalId"
- :title="$options.title"
- :aria-label="$options.title"
- icon="leave"
- variant="danger"
- />
- <leave-modal :member="member" />
- </div>
-</template>
diff --git a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
index fef7940eaa2..24500fbe44d 100644
--- a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
@@ -32,7 +32,6 @@ export default {
<template>
<gl-button
v-gl-tooltip.hover
- variant="danger"
:title="$options.i18n.buttonTitle"
:aria-label="$options.i18n.buttonTitle"
icon="remove"
diff --git a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
index 27c67e84675..4b3bb89da55 100644
--- a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
@@ -14,34 +14,13 @@ export default {
type: Number,
required: true,
},
- memberType: {
- type: String,
- required: false,
- default: null,
- },
message: {
type: String,
required: true,
},
title: {
type: String,
- required: false,
- default: null,
- },
- icon: {
- type: String,
- required: false,
- default: undefined,
- },
- buttonText: {
- type: String,
- required: false,
- default: '',
- },
- buttonCategory: {
- type: String,
- required: false,
- default: 'secondary',
+ required: true,
},
isAccessRequest: {
type: Boolean,
@@ -70,7 +49,6 @@ export default {
isAccessRequest: this.isAccessRequest,
isInvite: this.isInvite,
memberPath: this.memberPath.replace(':id', this.memberId),
- memberType: this.memberType,
message: this.message,
userDeletionObstacles: this.userDeletionObstacles,
};
@@ -89,13 +67,10 @@ export default {
<template>
<gl-button
v-gl-tooltip
- variant="danger"
- :category="buttonCategory"
:title="title"
:aria-label="title"
- :icon="icon"
+ icon="remove"
data-qa-selector="delete_member_button"
@click="showRemoveMemberModal(modalData)"
- ><template v-if="buttonText">{{ buttonText }}</template></gl-button
- >
+ />
</template>
diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
deleted file mode 100644
index 122e0a142a9..00000000000
--- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
+++ /dev/null
@@ -1,95 +0,0 @@
-<script>
-import { __, s__, sprintf } from '~/locale';
-import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
-import ActionButtonGroup from './action_button_group.vue';
-import LeaveButton from './leave_button.vue';
-import RemoveMemberButton from './remove_member_button.vue';
-
-export default {
- name: 'UserActionButtons',
- components: {
- ActionButtonGroup,
- RemoveMemberButton,
- LeaveButton,
- LdapOverrideButton: () =>
- import('ee_component/members/components/ldap/ldap_override_button.vue'),
- },
- props: {
- member: {
- type: Object,
- required: true,
- },
- isCurrentUser: {
- type: Boolean,
- required: true,
- },
- isInvitedUser: {
- type: Boolean,
- required: true,
- },
- permissions: {
- type: Object,
- required: true,
- },
- },
- computed: {
- message() {
- const { user, source } = this.member;
-
- if (user) {
- return sprintf(
- s__('Members|Are you sure you want to remove %{usersName} from "%{source}"?'),
- {
- usersName: user.name,
- source: source.fullName,
- },
- false,
- );
- }
-
- return sprintf(
- s__('Members|Are you sure you want to remove this orphaned member from "%{source}"?'),
- {
- source: source.fullName,
- },
- );
- },
- userDeletionObstaclesUserData() {
- return {
- name: this.member.user?.name,
- obstacles: parseUserDeletionObstacles(this.member.user),
- };
- },
- removeMemberButtonText() {
- return this.isInvitedUser ? null : __('Remove member');
- },
- removeMemberButtonIcon() {
- return this.isInvitedUser ? 'remove' : '';
- },
- removeMemberButtonCategory() {
- return this.isInvitedUser ? 'primary' : 'secondary';
- },
- },
-};
-</script>
-
-<template>
- <action-button-group>
- <div v-if="permissions.canRemove" class="gl-px-1">
- <leave-button v-if="isCurrentUser" :member="member" />
- <remove-member-button
- v-else
- :member-id="member.id"
- :member-type="member.type"
- :user-deletion-obstacles="userDeletionObstaclesUserData"
- :message="message"
- :icon="removeMemberButtonIcon"
- :button-text="removeMemberButtonText"
- :button-category="removeMemberButtonCategory"
- />
- </div>
- <div v-else-if="permissions.canOverride && !member.isOverridden" class="gl-px-1">
- <ldap-override-button :member="member" />
- </div>
- </action-button-group>
-</template>
diff --git a/app/assets/javascripts/members/components/action_dropdowns/constants.js b/app/assets/javascripts/members/components/action_dropdowns/constants.js
new file mode 100644
index 00000000000..8ccfc57dc28
--- /dev/null
+++ b/app/assets/javascripts/members/components/action_dropdowns/constants.js
@@ -0,0 +1,22 @@
+import { __, s__ } from '~/locale';
+
+export const I18N = {
+ actions: __('More actions'),
+ disableTwoFactor: s__('Members|Disable two-factor authentication'),
+ editPermissions: s__('Members|Edit permissions'),
+ leaveGroup: __('Leave group'),
+ removeMember: __('Remove member'),
+ confirmDisableTwoFactor: s__(
+ 'Members|Are you sure you want to disable the two-factor authentication for %{userName}?',
+ ),
+ confirmNormalUserRemoval: s__(
+ 'Members|Are you sure you want to remove %{userName} from "%{group}"?',
+ ),
+ confirmOrphanedUserRemoval: s__(
+ 'Members|Are you sure you want to remove this orphaned member from "%{group}"?',
+ ),
+ personalProjectOwnerCannotBeRemoved: s__("Members|A personal project's owner cannot be removed."),
+ lastGroupOwnerCannotBeRemoved: s__(
+ 'Members|A group must have at least one owner. To remove the member, assign a new owner.',
+ ),
+};
diff --git a/app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue b/app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue
new file mode 100644
index 00000000000..15606ad567c
--- /dev/null
+++ b/app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue
@@ -0,0 +1,36 @@
+<script>
+import { GlDropdownItem, GlModalDirective } from '@gitlab/ui';
+import { LEAVE_MODAL_ID } from '../../constants';
+import LeaveModal from '../modals/leave_modal.vue';
+
+export default {
+ name: 'LeaveGroupDropdownItem',
+ modalId: LEAVE_MODAL_ID,
+ components: {
+ GlDropdownItem,
+ LeaveModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ permissions: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown-item v-gl-modal="$options.modalId">
+ <span class="gl-text-red-500">
+ <slot></slot>
+ </span>
+ <leave-modal :member="member" :permissions="permissions" />
+ </gl-dropdown-item>
+</template>
diff --git a/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue b/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue
new file mode 100644
index 00000000000..f224aaa31f7
--- /dev/null
+++ b/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue
@@ -0,0 +1,86 @@
+<script>
+import { GlDropdownItem } from '@gitlab/ui';
+import { mapActions, mapState } from 'vuex';
+
+export default {
+ name: 'RemoveMemberDropdownItem',
+ components: { GlDropdownItem },
+ inject: ['namespace'],
+ props: {
+ memberId: {
+ type: Number,
+ required: true,
+ },
+ /**
+ * `GroupMember` (`app/models/members/group_member.rb`)
+ * or
+ * `ProjectMember` (`app/models/members/project_member.rb`).
+ */
+ memberModelType: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ modalMessage: {
+ type: String,
+ required: true,
+ },
+ isAccessRequest: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isInvite: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ userDeletionObstacles: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ preventRemoval: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapState({
+ memberPath(state) {
+ return state[this.namespace].memberPath;
+ },
+ }),
+ modalData() {
+ return {
+ isAccessRequest: this.isAccessRequest,
+ isInvite: this.isInvite,
+ memberPath: this.memberPath.replace(':id', this.memberId),
+ memberModelType: this.memberModelType,
+ message: this.modalMessage,
+ userDeletionObstacles: this.userDeletionObstacles,
+ preventRemoval: this.preventRemoval,
+ };
+ },
+ },
+ methods: {
+ ...mapActions({
+ showRemoveMemberModal(dispatch, payload) {
+ return dispatch(`${this.namespace}/showRemoveMemberModal`, payload);
+ },
+ }),
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown-item
+ data-qa-selector="delete_member_dropdown_item"
+ @click="showRemoveMemberModal(modalData)"
+ >
+ <span class="gl-text-red-500">
+ <slot></slot>
+ </span>
+ </gl-dropdown-item>
+</template>
diff --git a/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue
new file mode 100644
index 00000000000..8f5c32956a2
--- /dev/null
+++ b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue
@@ -0,0 +1,134 @@
+<script>
+import { GlDropdown, GlTooltipDirective } from '@gitlab/ui';
+import { sprintf } from '~/locale';
+import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
+import {
+ MEMBER_MODEL_TYPE_GROUP_MEMBER,
+ MEMBER_MODEL_TYPE_PROJECT_MEMBER,
+} from '~/members/constants';
+import { I18N } from './constants';
+import LeaveGroupDropdownItem from './leave_group_dropdown_item.vue';
+import RemoveMemberDropdownItem from './remove_member_dropdown_item.vue';
+
+export default {
+ name: 'UserActionDropdown',
+ i18n: I18N,
+ components: {
+ GlDropdown,
+ DisableTwoFactorDropdownItem: () =>
+ import(
+ 'ee_component/members/components/action_dropdowns/disable_two_factor_dropdown_item.vue'
+ ),
+ LdapOverrideDropdownItem: () =>
+ import('ee_component/members/components/ldap/ldap_override_dropdown_item.vue'),
+ LeaveGroupDropdownItem,
+ RemoveMemberDropdownItem,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ required: true,
+ },
+ permissions: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ modalDisableTwoFactor() {
+ const userName = this.member.user.username;
+ return sprintf(this.$options.i18n.confirmDisableTwoFactor, { userName }, false);
+ },
+ modalRemoveUser() {
+ const { user, source } = this.member;
+
+ if (this.permissions.canRemoveBlockedByLastOwner) {
+ if (this.member.type === MEMBER_MODEL_TYPE_PROJECT_MEMBER) {
+ return I18N.personalProjectOwnerCannotBeRemoved;
+ }
+
+ if (this.member.type === MEMBER_MODEL_TYPE_GROUP_MEMBER) {
+ return I18N.lastGroupOwnerCannotBeRemoved;
+ }
+ }
+
+ if (user) {
+ return sprintf(
+ this.$options.i18n.confirmNormalUserRemoval,
+ { userName: user.name, group: source.fullName },
+ false,
+ );
+ }
+
+ return sprintf(this.$options.i18n.confirmOrphanedUserRemoval, { group: source.fullName });
+ },
+ userDeletionObstaclesUserData() {
+ return {
+ name: this.member.user?.name,
+ obstacles: parseUserDeletionObstacles(this.member.user),
+ };
+ },
+ showDropdown() {
+ return (
+ this.permissions.canDisableTwoFactor || this.showLeaveOrRemove || this.showLdapOverride
+ );
+ },
+ showLeaveOrRemove() {
+ return this.permissions.canRemove || this.permissions.canRemoveBlockedByLastOwner;
+ },
+ showLdapOverride() {
+ return this.permissions.canOverride && !this.member.isOverridden;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ v-if="showDropdown"
+ v-gl-tooltip="$options.i18n.actions"
+ :text="$options.i18n.actions"
+ text-sr-only
+ icon="ellipsis_v"
+ category="tertiary"
+ no-caret
+ right
+ data-testid="user-action-dropdown"
+ data-qa-selector="user_action_dropdown"
+ >
+ <disable-two-factor-dropdown-item
+ v-if="permissions.canDisableTwoFactor"
+ :modal-message="modalDisableTwoFactor"
+ :user-id="member.user.id"
+ >
+ {{ $options.i18n.disableTwoFactor }}
+ </disable-two-factor-dropdown-item>
+
+ <template v-if="showLeaveOrRemove">
+ <leave-group-dropdown-item v-if="isCurrentUser" :member="member" :permissions="permissions">{{
+ $options.i18n.leaveGroup
+ }}</leave-group-dropdown-item>
+
+ <remove-member-dropdown-item
+ v-else
+ :member-id="member.id"
+ :member-model-type="member.type"
+ :user-deletion-obstacles="userDeletionObstaclesUserData"
+ :modal-message="modalRemoveUser"
+ :prevent-removal="permissions.canRemoveBlockedByLastOwner"
+ >{{ $options.i18n.removeMember }}</remove-member-dropdown-item
+ >
+ </template>
+
+ <ldap-override-dropdown-item v-else-if="showLdapOverride" :member="member">{{
+ $options.i18n.editPermissions
+ }}</ldap-override-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/members/components/modals/leave_modal.vue b/app/assets/javascripts/members/components/modals/leave_modal.vue
index e39669e17dd..8bc6aca9cc1 100644
--- a/app/assets/javascripts/members/components/modals/leave_modal.vue
+++ b/app/assets/javascripts/members/components/modals/leave_modal.vue
@@ -5,22 +5,30 @@ import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
-import { LEAVE_MODAL_ID } from '../../constants';
+import {
+ LEAVE_MODAL_ID,
+ MEMBER_MODEL_TYPE_GROUP_MEMBER,
+ MEMBER_MODEL_TYPE_PROJECT_MEMBER,
+} from '../../constants';
export default {
name: 'LeaveModal',
actionCancel: {
text: __('Cancel'),
},
- actionPrimary: {
- text: __('Leave'),
- attributes: {
- variant: 'danger',
- },
- },
csrf,
modalId: LEAVE_MODAL_ID,
- modalContent: s__('Members|Are you sure you want to leave "%{source}"?'),
+ i18n: {
+ title: s__('Members|Leave "%{source}"'),
+ body: s__('Members|Are you sure you want to leave "%{source}"?'),
+ preventedTitle: s__('Members|Cannot leave "%{source}"'),
+ preventedBodyProjectMemberModelType: s__(
+ 'Members|You cannot remove yourself from a personal project.',
+ ),
+ preventedBodyGroupMemberModelType: s__(
+ 'Members|A group must have at least one owner. To leave this group, assign a new owner.',
+ ),
+ },
components: { GlModal, GlForm, GlSprintf, UserDeletionObstaclesList },
directives: {
GlTooltip: GlTooltipDirective,
@@ -31,6 +39,10 @@ export default {
type: Object,
required: true,
},
+ permissions: {
+ type: Object,
+ required: true,
+ },
},
computed: {
...mapState({
@@ -42,7 +54,35 @@ export default {
return this.memberPath.replace(/:id$/, 'leave');
},
modalTitle() {
- return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.fullName });
+ return sprintf(
+ this.permissions.canRemoveBlockedByLastOwner
+ ? this.$options.i18n.preventedTitle
+ : this.$options.i18n.title,
+ { source: this.member.source.fullName },
+ );
+ },
+ preventedModalBody() {
+ if (this.member.type === MEMBER_MODEL_TYPE_PROJECT_MEMBER) {
+ return this.$options.i18n.preventedBodyProjectMemberModelType;
+ }
+
+ if (this.member.type === MEMBER_MODEL_TYPE_GROUP_MEMBER) {
+ return this.$options.i18n.preventedBodyGroupMemberModelType;
+ }
+
+ return null;
+ },
+ actionPrimary() {
+ if (this.permissions.canRemoveBlockedByLastOwner) {
+ return null;
+ }
+
+ return {
+ text: __('Leave'),
+ attributes: {
+ variant: 'danger',
+ },
+ };
},
obstacles() {
return parseUserDeletionObstacles(this.member.user);
@@ -64,13 +104,14 @@ export default {
v-bind="$attrs"
:modal-id="$options.modalId"
:title="modalTitle"
- :action-primary="$options.actionPrimary"
+ :action-primary="actionPrimary"
:action-cancel="$options.actionCancel"
@primary="handlePrimary"
>
<gl-form ref="form" :action="leavePath" method="post">
<p>
- <gl-sprintf :message="$options.modalContent">
+ <template v-if="permissions.canRemoveBlockedByLastOwner">{{ preventedModalBody }}</template>
+ <gl-sprintf v-else :message="$options.i18n.body">
<template #source>{{ member.source.fullName }}</template>
</gl-sprintf>
</p>
diff --git a/app/assets/javascripts/members/components/modals/remove_member_modal.vue b/app/assets/javascripts/members/components/modals/remove_member_modal.vue
index 1bb1f90302c..337379d8b4e 100644
--- a/app/assets/javascripts/members/components/modals/remove_member_modal.vue
+++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue
@@ -4,6 +4,7 @@ import { mapActions, mapState } from 'vuex';
import csrf from '~/lib/utils/csrf';
import { s__, __ } from '~/locale';
import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
+import { MEMBER_MODEL_TYPE_GROUP_MEMBER } from '../../constants';
export default {
actionCancel: {
@@ -27,8 +28,13 @@ export default {
memberPath(state) {
return state[this.namespace].removeMemberModalData.memberPath;
},
- memberType(state) {
- return state[this.namespace].removeMemberModalData.memberType;
+ /**
+ * `GroupMember` (`app/models/members/group_member.rb`)
+ * or
+ * `ProjectMember` (`app/models/members/project_member.rb`).
+ */
+ memberModelType(state) {
+ return state[this.namespace].removeMemberModalData.memberModelType;
},
message(state) {
return state[this.namespace].removeMemberModalData.message;
@@ -36,12 +42,15 @@ export default {
userDeletionObstacles(state) {
return state[this.namespace].removeMemberModalData.userDeletionObstacles ?? {};
},
+ preventRemoval(state) {
+ return state[this.namespace].removeMemberModalData.preventRemoval;
+ },
removeMemberModalVisible(state) {
return state[this.namespace].removeMemberModalVisible;
},
}),
isGroupMember() {
- return this.memberType === 'GroupMember';
+ return this.memberModelType === MEMBER_MODEL_TYPE_GROUP_MEMBER;
},
actionText() {
if (this.isAccessRequest) {
@@ -53,6 +62,10 @@ export default {
return __('Remove member');
},
actionPrimary() {
+ if (this.preventRemoval) {
+ return null;
+ }
+
return {
text: this.actionText,
attributes: {
@@ -95,21 +108,22 @@ export default {
>
<form ref="form" :action="memberPath" method="post">
<p>{{ message }}</p>
+ <template v-if="!preventRemoval">
+ <user-deletion-obstacles-list
+ v-if="hasObstaclesToUserDeletion"
+ :obstacles="userDeletionObstacles.obstacles"
+ :user-name="userDeletionObstacles.name"
+ />
- <user-deletion-obstacles-list
- v-if="hasObstaclesToUserDeletion"
- :obstacles="userDeletionObstacles.obstacles"
- :user-name="userDeletionObstacles.name"
- />
-
- <input ref="method" type="hidden" name="_method" value="delete" />
- <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
- <gl-form-checkbox v-if="isGroupMember" name="remove_sub_memberships">
- {{ __('Also remove direct user membership from subgroups and projects') }}
- </gl-form-checkbox>
- <gl-form-checkbox v-if="hasWorkspaceAccess" name="unassign_issuables">
- {{ __('Also unassign this user from related issues and merge requests') }}
- </gl-form-checkbox>
+ <input ref="method" type="hidden" name="_method" value="delete" />
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ <gl-form-checkbox v-if="isGroupMember" name="remove_sub_memberships">
+ {{ __('Also remove direct user membership from subgroups and projects') }}
+ </gl-form-checkbox>
+ <gl-form-checkbox v-if="hasWorkspaceAccess" name="unassign_issuables">
+ {{ __('Also unassign this user from related issues and merge requests') }}
+ </gl-form-checkbox>
+ </template>
</form>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/members/components/table/created_at.vue b/app/assets/javascripts/members/components/table/created_at.vue
index 0bad70894f9..44d124ad0db 100644
--- a/app/assets/javascripts/members/components/table/created_at.vue
+++ b/app/assets/javascripts/members/components/table/created_at.vue
@@ -1,10 +1,10 @@
<script>
import { GlSprintf } from '@gitlab/ui';
-import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import UserDate from '~/vue_shared/components/user_date.vue';
export default {
name: 'CreatedAt',
- components: { GlSprintf, TimeAgoTooltip },
+ components: { GlSprintf, UserDate },
props: {
date: {
type: String,
@@ -29,12 +29,12 @@ export default {
<span>
<gl-sprintf v-if="showCreatedBy" :message="s__('Members|%{time} by %{user}')">
<template #time>
- <time-ago-tooltip :time="date" />
+ <user-date :date="date" />
</template>
<template #user>
<a :href="createdBy.webUrl">{{ createdBy.name }}</a>
</template>
</gl-sprintf>
- <time-ago-tooltip v-else :time="date" />
+ <user-date v-else :date="date" />
</span>
</template>
diff --git a/app/assets/javascripts/members/components/table/member_action_buttons.vue b/app/assets/javascripts/members/components/table/member_action_buttons.vue
index ecc2ed82ad0..6ec7be608ba 100644
--- a/app/assets/javascripts/members/components/table/member_action_buttons.vue
+++ b/app/assets/javascripts/members/components/table/member_action_buttons.vue
@@ -3,12 +3,12 @@ import { MEMBER_TYPES, EE_ACTION_BUTTONS } from 'ee_else_ce/members/constants';
import AccessRequestActionButtons from '../action_buttons/access_request_action_buttons.vue';
import GroupActionButtons from '../action_buttons/group_action_buttons.vue';
import InviteActionButtons from '../action_buttons/invite_action_buttons.vue';
-import UserActionButtons from '../action_buttons/user_action_buttons.vue';
+import UserActionDropdown from '../action_dropdowns/user_action_dropdown.vue';
export default {
name: 'MemberActionButtons',
components: {
- UserActionButtons,
+ UserActionDropdown,
GroupActionButtons,
InviteActionButtons,
AccessRequestActionButtons,
@@ -32,15 +32,11 @@ export default {
type: Boolean,
required: true,
},
- isInvitedUser: {
- type: Boolean,
- required: true,
- },
},
computed: {
actionButtonComponent() {
const dictionary = {
- [MEMBER_TYPES.user]: 'user-action-buttons',
+ [MEMBER_TYPES.user]: 'user-action-dropdown',
[MEMBER_TYPES.group]: 'group-action-buttons',
[MEMBER_TYPES.invite]: 'invite-action-buttons',
[MEMBER_TYPES.accessRequest]: 'access-request-action-buttons',
@@ -60,6 +56,5 @@ export default {
:member="member"
:permissions="permissions"
:is-current-user="isCurrentUser"
- :is-invited-user="isInvitedUser"
/>
</template>
diff --git a/app/assets/javascripts/members/components/table/member_activity.vue b/app/assets/javascripts/members/components/table/member_activity.vue
new file mode 100644
index 00000000000..3b223cb1afa
--- /dev/null
+++ b/app/assets/javascripts/members/components/table/member_activity.vue
@@ -0,0 +1,38 @@
+<script>
+import UserDate from '~/vue_shared/components/user_date.vue';
+
+export default {
+ components: { UserDate },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ userCreated() {
+ return this.member.user?.createdAt;
+ },
+ lastActivity() {
+ return this.member.user?.lastActivityOn;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div v-if="userCreated">
+ <strong>{{ s__('Members|User created') }}:</strong>
+ <user-date :date="userCreated" />
+ </div>
+ <div v-if="member.createdAt">
+ <strong>{{ s__('Members|Access granted') }}:</strong>
+ <user-date :date="member.createdAt" />
+ </div>
+ <div v-if="lastActivity">
+ <strong>{{ s__('Members|Last activity') }}:</strong>
+ <user-date :date="lastActivity" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/members/components/table/member_source.vue b/app/assets/javascripts/members/components/table/member_source.vue
index 30fcbfcd3f8..ed1971d020b 100644
--- a/app/assets/javascripts/members/components/table/member_source.vue
+++ b/app/assets/javascripts/members/components/table/member_source.vue
@@ -1,11 +1,19 @@
<script>
-import { GlTooltipDirective } from '@gitlab/ui';
+import { GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
export default {
name: 'MemberSource',
+ i18n: {
+ inherited: __('Inherited'),
+ directMember: __('Direct member'),
+ directMemberWithCreatedBy: s__('Members|Direct member by %{createdBy}'),
+ inheritedMemberWithCreatedBy: s__('Members|%{group} by %{createdBy}'),
+ },
directives: {
GlTooltip: GlTooltipDirective,
},
+ components: { GlSprintf },
props: {
memberSource: {
type: Object,
@@ -15,13 +23,40 @@ export default {
type: Boolean,
required: true,
},
+ createdBy: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ showCreatedBy() {
+ return this.createdBy?.name && this.createdBy?.webUrl;
+ },
+ messageWithCreatedBy() {
+ return this.isDirectMember
+ ? this.$options.i18n.directMemberWithCreatedBy
+ : this.$options.i18n.inheritedMemberWithCreatedBy;
+ },
},
};
</script>
<template>
- <span v-if="isDirectMember">{{ __('Direct member') }}</span>
- <a v-else v-gl-tooltip.hover :title="__('Inherited')" :href="memberSource.webUrl">{{
+ <span v-if="showCreatedBy">
+ <gl-sprintf :message="messageWithCreatedBy">
+ <template #group>
+ <a v-gl-tooltip.hover="$options.i18n.inherited" :href="memberSource.webUrl">{{
+ memberSource.fullName
+ }}</a>
+ </template>
+ <template #createdBy>
+ <a :href="createdBy.webUrl">{{ createdBy.name }}</a>
+ </template>
+ </gl-sprintf>
+ </span>
+ <span v-else-if="isDirectMember">{{ $options.i18n.directMember }}</span>
+ <a v-else v-gl-tooltip.hover="$options.i18n.inherited" :href="memberSource.webUrl">{{
memberSource.fullName
}}</a>
</template>
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index 0512bc04085..8f03a298e63 100644
--- a/app/assets/javascripts/members/components/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -2,14 +2,20 @@
import { GlTable, GlBadge, GlPagination } from '@gitlab/ui';
import { mapState } from 'vuex';
import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue';
-import { canUnban, canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils';
+import {
+ canDisableTwoFactor,
+ canUnban,
+ canOverride,
+ canRemove,
+ canRemoveBlockedByLastOwner,
+ canResend,
+ canUpdate,
+} from 'ee_else_ce/members/utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
-import UserDate from '~/vue_shared/components/user_date.vue';
import {
FIELD_KEY_ACTIONS,
FIELDS,
ACTIVE_TAB_QUERY_PARAM_NAME,
- TAB_QUERY_PARAM_VALUES,
MEMBER_STATE_AWAITING,
MEMBER_STATE_ACTIVE,
USER_STATE_BLOCKED,
@@ -23,6 +29,7 @@ import ExpirationDatepicker from './expiration_datepicker.vue';
import MemberActionButtons from './member_action_buttons.vue';
import MemberAvatar from './member_avatar.vue';
import MemberSource from './member_source.vue';
+import MemberActivity from './member_activity.vue';
import RoleDropdown from './role_dropdown.vue';
export default {
@@ -40,11 +47,13 @@ export default {
RemoveGroupLinkModal,
RemoveMemberModal,
ExpirationDatepicker,
- UserDate,
+ MemberActivity,
+ DisableTwoFactorModal: () =>
+ import('ee_component/members/components/modals/disable_two_factor_modal.vue'),
LdapOverrideConfirmationModal: () =>
import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'),
},
- inject: ['namespace', 'currentUserId'],
+ inject: ['namespace', 'currentUserId', 'canManageMembers'],
props: {
tabQueryParamValue: {
type: String,
@@ -80,18 +89,17 @@ export default {
return paramName && currentPage && perPage && totalItems;
},
- isInvitedUser() {
- return this.tabQueryParamValue === TAB_QUERY_PARAM_VALUES.invite;
- },
},
methods: {
hasActionButtons(member) {
return (
canRemove(member) ||
+ canRemoveBlockedByLastOwner(member, this.canManageMembers) ||
canResend(member) ||
canUpdate(member, this.currentUserId) ||
canOverride(member) ||
- canUnban(member)
+ canUnban(member) ||
+ canDisableTwoFactor(member)
);
},
showField(field) {
@@ -249,7 +257,11 @@ export default {
<template #cell(source)="{ item: member }">
<members-table-cell #default="{ isDirectMember }" :member="member">
- <member-source :is-direct-member="isDirectMember" :member-source="member.source" />
+ <member-source
+ :is-direct-member="isDirectMember"
+ :member-source="member.source"
+ :created-by="member.createdBy"
+ />
</members-table-cell>
</template>
@@ -281,12 +293,8 @@ export default {
</members-table-cell>
</template>
- <template #cell(userCreatedAt)="{ item: member }">
- <user-date :date="member.user.createdAt" />
- </template>
-
- <template #cell(lastActivityOn)="{ item: member }">
- <user-date :date="member.user.lastActivityOn" />
+ <template #cell(activity)="{ item: member }">
+ <member-activity :member="member" />
</template>
<template #cell(actions)="{ item: member }">
@@ -294,7 +302,6 @@ export default {
<member-action-buttons
:member-type="memberType"
:is-current-user="isCurrentUser"
- :is-invited-user="isInvitedUser"
:permissions="permissions"
:member="member"
/>
@@ -317,6 +324,7 @@ export default {
:label-prev-page="__('Go to previous page')"
align="center"
/>
+ <disable-two-factor-modal />
<remove-group-link-modal />
<remove-member-modal />
<ldap-override-confirmation-modal />
diff --git a/app/assets/javascripts/members/components/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue
index 51eff428d63..407cbc55dd3 100644
--- a/app/assets/javascripts/members/components/table/members_table_cell.vue
+++ b/app/assets/javascripts/members/components/table/members_table_cell.vue
@@ -5,13 +5,14 @@ import {
isDirectMember,
isCurrentUser,
canRemove,
+ canRemoveBlockedByLastOwner,
canResend,
canUpdate,
} from '../../utils';
export default {
name: 'MembersTableCell',
- inject: ['currentUserId'],
+ inject: ['currentUserId', 'canManageMembers'],
props: {
member: {
type: Object,
@@ -45,6 +46,9 @@ export default {
isCurrentUser() {
return isCurrentUser(this.member, this.currentUserId);
},
+ canRemoveBlockedByLastOwner() {
+ return canRemoveBlockedByLastOwner(this.member, this.canManageMembers);
+ },
canRemove() {
return canRemove(this.member);
},
@@ -62,6 +66,7 @@ export default {
isCurrentUser: this.isCurrentUser,
permissions: {
canRemove: this.canRemove,
+ canRemoveBlockedByLastOwner: this.canRemoveBlockedByLastOwner,
canResend: this.canResend,
canUpdate: this.canUpdate,
},
diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue
index 6cd8bf57313..70808587d56 100644
--- a/app/assets/javascripts/members/components/table/role_dropdown.vue
+++ b/app/assets/javascripts/members/components/table/role_dropdown.vue
@@ -2,7 +2,9 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { mapActions } from 'vuex';
+import * as Sentry from '@sentry/browser';
import { s__ } from '~/locale';
+import { guestOverageConfirmAction } from 'ee_else_ce/members/guest_overage_confirm_action';
export default {
name: 'RoleDropdown',
@@ -11,7 +13,7 @@ export default {
GlDropdownItem,
LdapDropdownItem: () => import('ee_component/members/components/ldap/ldap_dropdown_item.vue'),
},
- inject: ['namespace'],
+ inject: ['namespace', 'group'],
props: {
member: {
type: Object,
@@ -30,7 +32,7 @@ export default {
},
computed: {
disabled() {
- return this.busy || (this.permissions.canOverride && !this.member.isOverridden);
+ return this.permissions.canOverride && !this.member.isOverridden;
},
},
mounted() {
@@ -50,22 +52,45 @@ export default {
return dispatch(`${this.namespace}/updateMemberRole`, payload);
},
}),
- handleSelect(value, name) {
- if (value === this.member.accessLevel.integerValue) {
+ async handleOverageConfirm(currentRoleValue, newRoleValue, newRoleName) {
+ return guestOverageConfirmAction({
+ currentRoleValue,
+ newRoleValue,
+ newRoleName,
+ group: this.group,
+ memberId: this.member.id,
+ memberType: this.namespace,
+ });
+ },
+ async handleSelect(newRoleValue, newRoleName) {
+ const currentRoleValue = this.member.accessLevel.integerValue;
+ if (newRoleValue === currentRoleValue) {
return;
}
this.busy = true;
+ const confirmed = await this.handleOverageConfirm(
+ currentRoleValue,
+ newRoleValue,
+ newRoleName,
+ );
+ if (!confirmed) {
+ this.busy = false;
+ return;
+ }
+
this.updateMemberRole({
memberId: this.member.id,
- accessLevel: { integerValue: value, stringValue: name },
+ accessLevel: { integerValue: newRoleValue, stringValue: newRoleName },
})
.then(() => {
this.$toast.show(s__('Members|Role updated successfully.'));
- this.busy = false;
})
- .catch(() => {
+ .catch((error) => {
+ Sentry.captureException(error);
+ })
+ .finally(() => {
this.busy = false;
});
},
@@ -80,6 +105,7 @@ export default {
:text="member.accessLevel.stringValue"
:header-text="__('Change role')"
:disabled="disabled"
+ :loading="busy"
>
<gl-dropdown-item
v-for="(value, name) in member.validRoles"
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index dab544c7cbc..68c5831db62 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -20,6 +20,7 @@ export const FIELD_KEY_MAX_ROLE = 'maxRole';
export const FIELD_KEY_USER_CREATED_AT = 'userCreatedAt';
export const FIELD_KEY_LAST_ACTIVITY_ON = 'lastActivityOn';
export const FIELD_KEY_EXPIRATION = 'expiration';
+export const FIELD_KEY_ACTIVITY = 'activity';
export const FIELD_KEY_LAST_SIGN_IN = 'lastSignIn';
export const FIELD_KEY_ACTIONS = 'actions';
@@ -41,8 +42,6 @@ export const FIELDS = [
{
key: FIELD_KEY_GRANTED,
label: __('Access granted'),
- thClass: 'col-meta',
- tdClass: 'col-meta',
sort: {
asc: 'last_joined',
desc: 'oldest_joined',
@@ -77,8 +76,14 @@ export const FIELDS = [
tdClass: 'col-expiration',
},
{
+ key: FIELD_KEY_ACTIVITY,
+ label: s__('Members|Activity'),
+ thClass: 'col-activity',
+ tdClass: 'col-activity',
+ },
+ {
key: FIELD_KEY_USER_CREATED_AT,
- label: __('Created on'),
+ label: s__('Members|User created'),
sort: {
asc: 'oldest_created_user',
desc: 'recent_created_user',
@@ -158,6 +163,12 @@ export const MEMBER_TYPES = {
accessRequest: 'accessRequest',
};
+// `app/models/members/group_member.rb`
+export const MEMBER_MODEL_TYPE_GROUP_MEMBER = 'GroupMember';
+
+// `app/models/members/project_member.rb`
+export const MEMBER_MODEL_TYPE_PROJECT_MEMBER = 'ProjectMember';
+
export const TAB_QUERY_PARAM_VALUES = {
group: 'groups',
invite: 'invited',
diff --git a/app/assets/javascripts/members/guest_overage_confirm_action.js b/app/assets/javascripts/members/guest_overage_confirm_action.js
new file mode 100644
index 00000000000..2205c3ad792
--- /dev/null
+++ b/app/assets/javascripts/members/guest_overage_confirm_action.js
@@ -0,0 +1,3 @@
+export const guestOverageConfirmAction = () => {
+ return true;
+};
diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js
index 359239c5c0c..c7398127727 100644
--- a/app/assets/javascripts/members/index.js
+++ b/app/assets/javascripts/members/index.js
@@ -21,6 +21,8 @@ export const initMembersApp = (el, options) => {
canExportMembers,
canFilterByEnterprise,
exportCsvPath,
+ groupName,
+ groupPath,
...vuexStoreAttributes
} = parseDataAttributes(el);
@@ -66,6 +68,10 @@ export const initMembersApp = (el, options) => {
canFilterByEnterprise,
canExportMembers,
exportCsvPath,
+ group: {
+ name: groupName,
+ path: groupPath,
+ },
},
render: (createElement) => createElement('members-tabs'),
});
diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js
index bf87ab53d36..09e4b5e8a6f 100644
--- a/app/assets/javascripts/members/utils.js
+++ b/app/assets/javascripts/members/utils.js
@@ -51,6 +51,9 @@ export const canRemove = (member) => {
return isDirectMember(member) && member.canRemove;
};
+export const canRemoveBlockedByLastOwner = (member, canManageMembers) =>
+ isDirectMember(member) && canManageMembers && member.isLastOwner;
+
export const canResend = (member) => {
return Boolean(member.invite?.canResend);
};
@@ -106,6 +109,9 @@ export const buildSortHref = ({
};
// Defined in `ee/app/assets/javascripts/members/utils.js`
+export const canDisableTwoFactor = () => false;
+
+// Defined in `ee/app/assets/javascripts/members/utils.js`
export const canOverride = () => false;
// Defined in `ee/app/assets/javascripts/members/utils.js`
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 5a1410ceeba..46ee8fecfc5 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-new, class-methods-use-this */
+/* eslint-disable class-methods-use-this */
import $ from 'jquery';
import Vue from 'vue';
import { createAlert } from '~/flash';
@@ -134,8 +134,8 @@ function destroyPipelines(app) {
return null;
}
-function loadDiffs({ url, sticky }) {
- return axios.get(`${url}.json${location.search}`).then(({ data }) => {
+function loadDiffs({ url, sticky, tabs }) {
+ return axios.get(url).then(({ data }) => {
const $container = $('#diffs');
$container.html(data.html);
initDiffStatsDropdown(sticky);
@@ -143,7 +143,9 @@ function loadDiffs({ url, sticky }) {
localTimeAgo(document.querySelectorAll('#diffs .js-timeago'));
syntaxHighlight($('#diffs .js-syntax-highlight'));
- new Diff();
+ tabs.createDiff();
+ tabs.setHubToDiff();
+
scrollToContainer('#diffs');
$('.diff-file').each((i, el) => {
@@ -204,6 +206,7 @@ export default class MergeRequestTabs {
this.currentTab = null;
this.diffsLoaded = false;
+ this.diffsClass = null;
this.commitsLoaded = false;
this.fixedLayoutPref = null;
this.eventHub = createEventHub();
@@ -211,6 +214,7 @@ export default class MergeRequestTabs {
this.setUrl = setUrl !== undefined ? setUrl : true;
this.setCurrentAction = this.setCurrentAction.bind(this);
+ this.switchViewType = this.switchViewType.bind(this);
this.tabShown = this.tabShown.bind(this);
this.clickTab = this.clickTab.bind(this);
@@ -230,11 +234,13 @@ export default class MergeRequestTabs {
this.tabShown(action, location.href);
this.eventHub.$emit('MergeRequestTabChange', action);
});
+ this.eventHub.$on('diff:switch-view-type', this.switchViewType);
}
// Used in tests
unbindEvents() {
$('.merge-request-tabs a[data-toggle="tabvue"]').off('click', this.clickTab);
+ this.eventHub.$off('diff:switch-view-type', this.switchViewType);
}
storeScroll() {
@@ -341,7 +347,7 @@ export default class MergeRequestTabs {
in practice, this only occurs when comparing commits in
the new merge request form page.
*/
- this.loadDiff(href);
+ this.loadDiff({ endpoint: href, strip: true });
}
// this.hideSidebar();
this.expandViewContainer();
@@ -503,17 +509,20 @@ export default class MergeRequestTabs {
}
// load the diff tab content from the backend
- loadDiff(source) {
+ loadDiff({ endpoint, strip = true }) {
if (this.diffsLoaded) {
document.dispatchEvent(new CustomEvent('scroll'));
return;
}
+ // We extract pathname for the current Changes tab anchor href
+ // some pages like MergeRequestsController#new has query parameters on that anchor
+ const diffUrl = strip ? `${parseUrlPathname(endpoint)}.json${location.search}` : endpoint;
+
loadDiffs({
- // We extract pathname for the current Changes tab anchor href
- // some pages like MergeRequestsController#new has query parameters on that anchor
- url: parseUrlPathname(source),
+ url: diffUrl,
sticky: computeTopOffset(this.mergeRequestTabs),
+ tabs: this,
})
.then(() => {
if (this.isDiffAction(this.currentAction)) {
@@ -528,6 +537,21 @@ export default class MergeRequestTabs {
});
});
}
+ switchViewType({ source }) {
+ this.diffsLoaded = false;
+
+ this.loadDiff({ endpoint: source, strip: false });
+ }
+ createDiff() {
+ if (!this.diffsClass) {
+ this.diffsClass = new Diff({ mergeRequestEventHub: this.eventHub });
+ }
+ }
+ setHubToDiff() {
+ if (this.diffsClass) {
+ this.diffsClass.mrHub = this.eventHub;
+ }
+ }
diffViewType() {
return $('.js-diff-view-buttons button.active').data('viewType');
diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue
index 4a675cf7563..6af1baaa37e 100644
--- a/app/assets/javascripts/merge_requests/components/sticky_header.vue
+++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue
@@ -56,11 +56,7 @@ export default {
},
watch: {
discussionTabCounter(val) {
- if (this.glFeatures.paginatedMrDiscussions) {
- if (this.doneFetchingBatchDiscussions) {
- this.discussionCounter = val;
- }
- } else {
+ if (this.doneFetchingBatchDiscussions) {
this.discussionCounter = val;
}
},
@@ -86,8 +82,8 @@ export default {
@disappear="setStickyHeaderVisible(true)"
>
<div
- v-if="isStickyHeaderVisible"
class="issue-sticky-header merge-request-sticky-header gl-fixed gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-pt-3 gl-display-none gl-md-display-block"
+ :class="{ 'gl-visibility-hidden': !isStickyHeaderVisible }"
>
<div
class="issue-sticky-header-text gl-display-flex gl-flex-direction-column gl-align-items-center gl-mx-auto gl-px-5"
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue
index 5f54f24e24c..0bb2a913dec 100644
--- a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue
+++ b/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue
@@ -19,6 +19,25 @@ export default {
artifactsLabel: __('Artifacts'),
parametersLabel: __('Parameters'),
metricsLabel: __('Metrics'),
+ metadataLabel: __('Metadata'),
+ },
+ computed: {
+ sections() {
+ return [
+ {
+ sectionName: this.$options.i18n.parametersLabel,
+ sectionValues: this.candidate.params,
+ },
+ {
+ sectionName: this.$options.i18n.metricsLabel,
+ sectionValues: this.candidate.metrics,
+ },
+ {
+ sectionName: this.$options.i18n.metadataLabel,
+ sectionValues: this.candidate.metadata,
+ },
+ ];
+ },
},
};
</script>
@@ -67,27 +86,18 @@ export default {
</td>
</tr>
- <tr class="divider"></tr>
-
- <tr v-for="(param, index) in candidate.params" :key="param.name">
- <td v-if="index == 0" class="gl-text-secondary gl-font-weight-bold">
- {{ $options.i18n.parametersLabel }}
- </td>
- <td v-else></td>
- <td class="gl-font-weight-bold">{{ param.name }}</td>
- <td>{{ param.value }}</td>
- </tr>
+ <template v-for="{ sectionName, sectionValues } in sections">
+ <tr :key="sectionName" class="divider"></tr>
- <tr class="divider"></tr>
-
- <tr v-for="(metric, index) in candidate.metrics" :key="metric.name">
- <td v-if="index == 0" class="gl-text-secondary gl-font-weight-bold">
- {{ $options.i18n.metricsLabel }}
- </td>
- <td v-else></td>
- <td class="gl-font-weight-bold">{{ metric.name }}</td>
- <td>{{ metric.value }}</td>
- </tr>
+ <tr v-for="(item, index) in sectionValues" :key="item.name">
+ <td v-if="index === 0" class="gl-text-secondary gl-font-weight-bold">
+ {{ sectionName }}
+ </td>
+ <td v-else></td>
+ <td class="gl-font-weight-bold">{{ item.name }}</td>
+ <td>{{ item.value }}</td>
+ </tr>
+ </template>
</tbody>
</table>
</div>
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue
index f8e269d3b57..5d13122765a 100644
--- a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue
+++ b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue
@@ -1,6 +1,8 @@
<script>
-import { GlTable, GlLink } from '@gitlab/ui';
+import { GlTable, GlLink, GlPagination, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
+import { getParameterValues, setUrlParams } from '~/lib/utils/url_utility';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import IncubationAlert from './incubation_alert.vue';
export default {
@@ -8,24 +10,55 @@ export default {
components: {
GlTable,
GlLink,
+ TimeAgo,
IncubationAlert,
+ GlPagination,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['candidates', 'metricNames', 'paramNames', 'pagination'],
+ data() {
+ return {
+ page: parseInt(getParameterValues('page')[0], 10) || 1,
+ };
},
- inject: ['candidates', 'metricNames', 'paramNames'],
computed: {
fields() {
return [
+ { key: 'name', label: this.$options.i18n.nameLabel },
+ { key: 'created_at', label: this.$options.i18n.createdAtLabel },
+ { key: 'user', label: this.$options.i18n.userLabel },
...this.paramNames,
...this.metricNames,
{ key: 'details', label: '' },
{ key: 'artifact', label: '' },
];
},
+ displayPagination() {
+ return this.candidates.length > 0;
+ },
+ prevPage() {
+ return this.pagination.page > 1 ? this.pagination.page - 1 : null;
+ },
+ nextPage() {
+ return !this.pagination.isLastPage ? this.pagination.page + 1 : null;
+ },
+ },
+ methods: {
+ generateLink(page) {
+ return setUrlParams({ page });
+ },
},
i18n: {
titleLabel: __('Experiment candidates'),
emptyStateLabel: __('This experiment has no logged candidates'),
artifactsLabel: __('Artifacts'),
detailsLabel: __('Details'),
+ userLabel: __('User'),
+ createdAtLabel: __('Created at'),
+ nameLabel: __('Name'),
+ noDataContent: __('-'),
},
};
</script>
@@ -43,17 +76,59 @@ export default {
:items="candidates"
:empty-text="$options.i18n.emptyStateLabel"
show-empty
- class="gl-mt-0!"
+ small
+ class="gl-mt-0! ml-candidate-table"
>
+ <template #cell()="data">
+ <div v-gl-tooltip.hover :title="data.value">{{ data.value }}</div>
+ </template>
+
<template #cell(artifact)="data">
- <gl-link v-if="data.value" :href="data.value" target="_blank">{{
- $options.i18n.artifactsLabel
- }}</gl-link>
+ <gl-link
+ v-if="data.value"
+ v-gl-tooltip.hover
+ :href="data.value"
+ target="_blank"
+ :title="$options.i18n.artifactsLabel"
+ >{{ $options.i18n.artifactsLabel }}</gl-link
+ >
+ <div v-else v-gl-tooltip.hover :title="$options.i18n.artifactsLabel">
+ {{ $options.i18n.noDataContent }}
+ </div>
</template>
<template #cell(details)="data">
- <gl-link :href="data.value">{{ $options.i18n.detailsLabel }}</gl-link>
+ <gl-link v-gl-tooltip.hover :href="data.value" :title="$options.i18n.detailsLabel">{{
+ $options.i18n.detailsLabel
+ }}</gl-link>
+ </template>
+
+ <template #cell(created_at)="data">
+ <time-ago v-gl-tooltip.hover :time="data.value" :title="data.value" />
+ </template>
+
+ <template #cell(user)="data">
+ <gl-link
+ v-if="data.value"
+ v-gl-tooltip.hover
+ :href="data.value.path"
+ :title="data.value.username"
+ >@{{ data.value.username }}</gl-link
+ >
+ <div v-else>{{ $options.i18n.noDataContent }}</div>
</template>
</gl-table>
+
+ <gl-pagination
+ v-if="displayPagination"
+ v-model="pagination.page"
+ :prev-page="prevPage"
+ :next-page="nextPage"
+ :total-items="pagination.totalItems"
+ :per-page="pagination.perPage"
+ :link-gen="generateLink"
+ align="center"
+ class="w-100"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/requests/index.js b/app/assets/javascripts/monitoring/requests/index.js
index 8b65eec051f..29786a79c56 100644
--- a/app/assets/javascripts/monitoring/requests/index.js
+++ b/app/assets/javascripts/monitoring/requests/index.js
@@ -1,7 +1,9 @@
import axios from '~/lib/utils/axios_utils';
import { backOff } from '~/lib/utils/common_utils';
-import statusCodes, {
+import {
+ HTTP_STATUS_BAD_REQUEST,
HTTP_STATUS_NO_CONTENT,
+ HTTP_STATUS_SERVICE_UNAVAILABLE,
HTTP_STATUS_UNPROCESSABLE_ENTITY,
} from '~/lib/utils/http_status';
import { PROMETHEUS_TIMEOUT } from '../constants';
@@ -36,9 +38,9 @@ export const getPrometheusQueryData = (prometheusEndpoint, params, opts) =>
// https://prometheus.io/docs/prometheus/latest/querying/api/#format-overview
const { response = {} } = error;
if (
- response.status === statusCodes.BAD_REQUEST ||
+ response.status === HTTP_STATUS_BAD_REQUEST ||
response.status === HTTP_STATUS_UNPROCESSABLE_ENTITY ||
- response.status === statusCodes.SERVICE_UNAVAILABLE
+ response.status === HTTP_STATUS_SERVICE_UNAVAILABLE
) {
const { data } = response;
if (data?.status === 'error' && data?.error) {
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index e0eaf76b5f6..5fab292b6df 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -1,7 +1,7 @@
import { pick } from 'lodash';
import Vue from 'vue';
import { BACKOFF_TIMEOUT } from '~/lib/utils/common_utils';
-import httpStatusCodes from '~/lib/utils/http_status';
+import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_SERVICE_UNAVAILABLE } from '~/lib/utils/http_status';
import { dashboardEmptyStates, endpointKeys, initialStateKeys, metricStates } from '../constants';
import * as types from './mutation_types';
import { mapToDashboardViewModel, mapPanelToViewModel, normalizeQueryResponseData } from './utils';
@@ -43,9 +43,9 @@ const emptyStateFromError = (error) => {
// Axios error responses
const { response } = error;
- if (response && response.status === httpStatusCodes.SERVICE_UNAVAILABLE) {
+ if (response && response.status === HTTP_STATUS_SERVICE_UNAVAILABLE) {
return metricStates.CONNECTION_FAILED;
- } else if (response && response.status === httpStatusCodes.BAD_REQUEST) {
+ } else if (response && response.status === HTTP_STATUS_BAD_REQUEST) {
// Note: "error.response.data.error" may contain Prometheus error information
return metricStates.BAD_QUERY;
}
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
index e10605609b0..f5f10aa4a9b 100644
--- a/app/assets/javascripts/mr_notes/init_notes.js
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import { mapActions, mapState, mapGetters } from 'vuex';
+import { renderGFM } from '~/behaviors/markdown/render_gfm';
import { parseBoolean } from '~/lib/utils/common_utils';
import store from '~/mr_notes/stores';
import notesEventHub from '~/notes/event_hub';
@@ -9,6 +10,13 @@ import { getNotesFilterData } from '../notes/utils/get_notes_filter_data';
import initWidget from '../vue_merge_request_widget';
export default () => {
+ requestIdleCallback(
+ () => {
+ renderGFM(document.getElementById('diff-notes-app'));
+ },
+ { timeout: 500 },
+ );
+
const el = document.getElementById('js-vue-mr-discussions');
if (!el) {
return;
diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue
index ef59140115d..7b0076cc5d4 100644
--- a/app/assets/javascripts/nav/components/new_nav_toggle.vue
+++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue
@@ -3,6 +3,7 @@ import { GlBadge, GlToggle } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { createAlert } from '~/flash';
import { s__ } from '~/locale';
+import Tracking from '~/tracking';
export default {
i18n: {
@@ -34,9 +35,19 @@ export default {
};
},
methods: {
- async toggleNav() {
+ toggleNav() {
+ this.isEnabled = !this.isEnabled;
+ this.updateAndReload();
+ },
+ async updateAndReload() {
try {
- await axios.put(this.endpoint, { user: { use_new_navigation: !this.enabled } });
+ await axios.put(this.endpoint, { user: { use_new_navigation: this.isEnabled } });
+
+ Tracking.event(undefined, 'click_toggle', {
+ label: this.enabled ? 'disable_new_nav_beta' : 'enable_new_nav_beta',
+ property: 'navigation',
+ });
+
window.location.reload();
} catch (error) {
createAlert({
@@ -55,17 +66,15 @@ export default {
class="gl-px-4 gl-py-2 gl-display-flex gl-justify-content-space-between gl-align-items-center"
>
<b>{{ $options.i18n.sectionTitle }}</b>
- <gl-badge>{{ $options.i18n.badgeLabel }}</gl-badge>
+ <gl-badge variant="info">{{ $options.i18n.badgeLabel }}</gl-badge>
</div>
- <div class="menu-item gl-display-flex! gl-justify-content-space-between gl-align-items-center">
+ <div
+ class="menu-item gl-cursor-pointer gl-display-flex! gl-justify-content-space-between gl-align-items-center"
+ @click.prevent.stop="toggleNav"
+ >
{{ $options.i18n.toggleMenuItemLabel }}
- <gl-toggle
- v-model="isEnabled"
- :label="$options.i18n.toggleLabel"
- label-position="hidden"
- @change="toggleNav"
- />
+ <gl-toggle :value="isEnabled" :label="$options.i18n.toggleLabel" label-position="hidden" />
</div>
</li>
</template>
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 2ccb9a0b514..c6e7117cf2e 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -312,7 +312,7 @@ export default {
if (this.isLoggedIn) {
const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
- this.autosave = new Autosave($(this.$refs.textarea), [
+ this.autosave = new Autosave(this.$refs.textarea, [
this.$options.i18n.note,
noteableType,
this.getNoteableData.id,
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 36f7d720e48..79b6139d4b1 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -1,6 +1,7 @@
<script>
import { GlIcon, GlBadge, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { mapActions } from 'vuex';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -74,6 +75,12 @@ export default {
};
},
computed: {
+ authorId() {
+ return getIdFromGraphQLId(this.author.id);
+ },
+ authorHref() {
+ return this.author.path || this.author.webUrl;
+ },
toggleChevronIconName() {
return this.expanded ? 'chevron-up' : 'chevron-down';
},
@@ -145,9 +152,9 @@ export default {
<template v-if="hasAuthor">
<a
ref="authorNameLink"
- :href="author.path"
+ :href="authorHref"
:class="authorLinkClasses"
- :data-user-id="author.id"
+ :data-user-id="authorId"
:data-username="author.username"
>
<span class="note-header-author-name gl-font-weight-bold">
diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js
index 61cb4ab2a10..17272d5abef 100644
--- a/app/assets/javascripts/notes/mixins/autosave.js
+++ b/app/assets/javascripts/notes/mixins/autosave.js
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import { s__ } from '~/locale';
import Autosave from '~/autosave';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
@@ -16,7 +15,7 @@ export default {
keys = keys.concat(extraKeys);
}
- this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), keys);
+ this.autosave = new Autosave(this.$refs.noteForm.$refs.textarea, keys);
},
resetAutoSave() {
this.autosave.reset();
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index d290a8ccb84..5cad091ce2c 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -101,7 +101,7 @@ export const fetchDiscussions = (
if (
getters.noteableType === constants.ISSUE_NOTEABLE_TYPE ||
- window.gon?.features?.paginatedMrDiscussions
+ getters.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE
) {
return dispatch('fetchDiscussionsBatch', { path, config, perPage: 20 });
}
diff --git a/app/assets/javascripts/notifications/components/notification_email_listbox_input.vue b/app/assets/javascripts/notifications/components/notification_email_listbox_input.vue
new file mode 100644
index 00000000000..5d5524deb0d
--- /dev/null
+++ b/app/assets/javascripts/notifications/components/notification_email_listbox_input.vue
@@ -0,0 +1,46 @@
+<script>
+import ListboxInput from '~/vue_shared/components/listbox_input/listbox_input.vue';
+
+export default {
+ components: {
+ ListboxInput,
+ },
+ inject: ['label', 'name', 'emails', 'emptyValueText', 'value', 'disabled'],
+ data() {
+ return {
+ selected: this.value,
+ };
+ },
+ computed: {
+ options() {
+ return [
+ {
+ value: '',
+ text: this.emptyValueText,
+ },
+ ...this.emails.map((email) => ({
+ text: email,
+ value: email,
+ })),
+ ];
+ },
+ },
+ methods: {
+ async onSelect() {
+ await this.$nextTick();
+ this.$el.closest('form').submit();
+ },
+ },
+};
+</script>
+
+<template>
+ <listbox-input
+ v-model="selected"
+ :label="label"
+ :name="name"
+ :items="options"
+ :disabled="disabled"
+ @select="onSelect"
+ />
+</template>
diff --git a/app/assets/javascripts/notifications/index.js b/app/assets/javascripts/notifications/index.js
index a81f2c2590b..1395084f68c 100644
--- a/app/assets/javascripts/notifications/index.js
+++ b/app/assets/javascripts/notifications/index.js
@@ -2,10 +2,37 @@ import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import NotificationsDropdown from './components/notifications_dropdown.vue';
+import NotificationEmailListboxInput from './components/notification_email_listbox_input.vue';
Vue.use(GlToast);
+const initNotificationEmailListboxInputs = () => {
+ const els = [...document.querySelectorAll('.js-notification-email-listbox-input')];
+
+ els.forEach((el, index) => {
+ const { label, name, emptyValueText, value = '' } = el.dataset;
+
+ return new Vue({
+ el,
+ name: `NotificationEmailListboxInputRoot${index + 1}`,
+ provide: {
+ label,
+ name,
+ emails: JSON.parse(el.dataset.emails),
+ emptyValueText,
+ value,
+ disabled: parseBoolean(el.dataset.disabled),
+ },
+ render(h) {
+ return h(NotificationEmailListboxInput);
+ },
+ });
+ });
+};
+
export default () => {
+ initNotificationEmailListboxInputs();
+
const containers = document.querySelectorAll('.js-vue-notification-dropdown');
if (!containers.length) return false;
diff --git a/app/assets/javascripts/observability/components/observability_app.vue b/app/assets/javascripts/observability/components/observability_app.vue
index 33d23ea043b..ff9cf6ff6c5 100644
--- a/app/assets/javascripts/observability/components/observability_app.vue
+++ b/app/assets/javascripts/observability/components/observability_app.vue
@@ -2,7 +2,7 @@
import { darkModeEnabled } from '~/lib/utils/color_utils';
import { setUrlParams } from '~/lib/utils/url_utility';
-import { MESSAGE_EVENT_TYPE, OBSERVABILITY_ROUTES, SKELETON_VARIANT } from '../constants';
+import { MESSAGE_EVENT_TYPE, SKELETON_VARIANTS_BY_ROUTE } from '../constants';
import ObservabilitySkeleton from './skeleton/index.vue';
export default {
@@ -23,16 +23,16 @@ export default {
);
},
getSkeletonVariant() {
- switch (this.$route.path) {
- case OBSERVABILITY_ROUTES.DASHBOARDS:
- return SKELETON_VARIANT.DASHBOARDS;
- case OBSERVABILITY_ROUTES.EXPLORE:
- return SKELETON_VARIANT.EXPLORE;
- case OBSERVABILITY_ROUTES.MANAGE:
- return SKELETON_VARIANT.MANAGE;
- default:
- return SKELETON_VARIANT.DASHBOARDS;
- }
+ const [, variant] =
+ Object.entries(SKELETON_VARIANTS_BY_ROUTE).find(([path]) =>
+ this.$route.path.endsWith(path),
+ ) || [];
+
+ const DEFAULT_SKELETON = 'dashboards';
+
+ if (!variant) return DEFAULT_SKELETON;
+
+ return variant;
},
},
mounted() {
@@ -51,7 +51,7 @@ export default {
} = e;
switch (type) {
case MESSAGE_EVENT_TYPE.GOUI_LOADED:
- this.$refs.iframeSkeleton.handleSkeleton();
+ this.$refs.observabilitySkeleton.onContentLoaded();
break;
case MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE:
this.routeUpdateHandler(payload);
@@ -80,7 +80,7 @@ export default {
</script>
<template>
- <observability-skeleton ref="iframeSkeleton" :variant="getSkeletonVariant">
+ <observability-skeleton ref="observabilitySkeleton" :variant="getSkeletonVariant">
<iframe
id="observability-ui-iframe"
data-testid="observability-ui-iframe"
diff --git a/app/assets/javascripts/observability/components/skeleton/index.vue b/app/assets/javascripts/observability/components/skeleton/index.vue
index 1e2671c8166..c8f196a43f4 100644
--- a/app/assets/javascripts/observability/components/skeleton/index.vue
+++ b/app/assets/javascripts/observability/components/skeleton/index.vue
@@ -1,17 +1,32 @@
<script>
-import { GlSkeletonLoader } from '@gitlab/ui';
-import { SKELETON_VARIANT } from '../../constants';
+import { GlSkeletonLoader, GlAlert } from '@gitlab/ui';
+
+import {
+ SKELETON_VARIANTS_BY_ROUTE,
+ SKELETON_STATE,
+ DEFAULT_TIMERS,
+ OBSERVABILITY_ROUTES,
+ TIMEOUT_ERROR_LABEL,
+ TIMEOUT_ERROR_MESSAGE,
+} from '../../constants';
import DashboardsSkeleton from './dashboards.vue';
import ExploreSkeleton from './explore.vue';
import ManageSkeleton from './manage.vue';
export default {
- SKELETON_VARIANT,
components: {
GlSkeletonLoader,
DashboardsSkeleton,
ExploreSkeleton,
ManageSkeleton,
+ GlAlert,
+ },
+ SKELETON_VARIANTS_BY_ROUTE,
+ SKELETON_STATE,
+ OBSERVABILITY_ROUTES,
+ i18n: {
+ TIMEOUT_ERROR_LABEL,
+ TIMEOUT_ERROR_MESSAGE,
},
props: {
variant: {
@@ -22,65 +37,94 @@ export default {
},
data() {
return {
- loading: null,
- timerId: null,
+ state: null,
+ loadingTimeout: null,
+ errorTimeout: null,
};
},
mounted() {
- this.timerId = setTimeout(() => {
- /**
- * If observability UI is not loaded then this.loading would be null
- * we will show skeleton in that case
- */
- if (this.loading !== false) {
- this.showSkeleton();
- }
- }, 500);
+ this.setLoadingTimeout();
+ this.setErrorTimeout();
+ },
+ destroyed() {
+ clearTimeout(this.loadingTimeout);
+ clearTimeout(this.errorTimeout);
},
methods: {
- handleSkeleton() {
- if (this.loading === null) {
+ onContentLoaded() {
+ clearTimeout(this.errorTimeout);
+ clearTimeout(this.loadingTimeout);
+
+ this.hideSkeleton();
+ },
+ setLoadingTimeout() {
+ this.loadingTimeout = setTimeout(() => {
/**
- * If observability UI content loads with in 500ms
- * do not show skeleton.
+ * If content is not loaded within CONTENT_WAIT_MS,
+ * show the skeleton
*/
- clearTimeout(this.timerId);
- return;
- }
-
- /**
- * If observability UI content loads after 500ms
- * wait for 400ms to hide skeleton.
- * This is mostly to avoid the flashing effect If content loads imediately after skeleton
- */
- setTimeout(this.hideSkeleton, 400);
+ if (this.state !== SKELETON_STATE.HIDDEN) {
+ this.showSkeleton();
+ }
+ }, DEFAULT_TIMERS.CONTENT_WAIT_MS);
+ },
+ setErrorTimeout() {
+ this.errorTimeout = setTimeout(() => {
+ /**
+ * If content is not loaded within TIMEOUT_MS,
+ * show the error dialog
+ */
+ if (this.state !== SKELETON_STATE.HIDDEN) {
+ this.showError();
+ }
+ }, DEFAULT_TIMERS.TIMEOUT_MS);
},
hideSkeleton() {
- this.loading = false;
+ this.state = SKELETON_STATE.HIDDEN;
},
showSkeleton() {
- this.loading = true;
+ this.state = SKELETON_STATE.VISIBLE;
+ },
+ showError() {
+ this.state = SKELETON_STATE.ERROR;
+ },
+
+ isSkeletonShown(route) {
+ return this.variant === SKELETON_VARIANTS_BY_ROUTE[route];
},
},
};
</script>
<template>
<div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch">
- <div v-show="loading" class="gl-px-5">
- <dashboards-skeleton v-if="variant === $options.SKELETON_VARIANT.DASHBOARDS" />
- <explore-skeleton v-else-if="variant === $options.SKELETON_VARIANT.EXPLORE" />
- <manage-skeleton v-else-if="variant === $options.SKELETON_VARIANT.MANAGE" />
+ <transition name="fade">
+ <div v-if="state === $options.SKELETON_STATE.VISIBLE" class="gl-px-5">
+ <dashboards-skeleton v-if="isSkeletonShown($options.OBSERVABILITY_ROUTES.DASHBOARDS)" />
+ <explore-skeleton v-else-if="isSkeletonShown($options.OBSERVABILITY_ROUTES.EXPLORE)" />
+ <manage-skeleton v-else-if="isSkeletonShown($options.OBSERVABILITY_ROUTES.MANAGE)" />
- <gl-skeleton-loader v-else>
- <rect y="2" width="10" height="8" />
- <rect y="2" x="15" width="15" height="8" />
- <rect y="2" x="35" width="15" height="8" />
- <rect y="15" width="400" height="30" />
- </gl-skeleton-loader>
- </div>
+ <gl-skeleton-loader v-else>
+ <rect y="2" width="10" height="8" />
+ <rect y="2" x="15" width="15" height="8" />
+ <rect y="2" x="35" width="15" height="8" />
+ <rect y="15" width="400" height="30" />
+ </gl-skeleton-loader>
+ </div>
+ </transition>
+
+ <gl-alert
+ v-if="state === $options.SKELETON_STATE.ERROR"
+ :title="$options.i18n.TIMEOUT_ERROR_LABEL"
+ variant="danger"
+ :dismissible="false"
+ class="gl-m-5"
+ >
+ {{ $options.i18n.TIMEOUT_ERROR_MESSAGE }}
+ </gl-alert>
<div
- v-show="!loading"
+ v-show="state === $options.SKELETON_STATE.HIDDEN"
+ data-testid="observability-wrapper"
class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch"
>
<slot></slot>
diff --git a/app/assets/javascripts/observability/constants.js b/app/assets/javascripts/observability/constants.js
index 74dd543e285..e4827dd169f 100644
--- a/app/assets/javascripts/observability/constants.js
+++ b/app/assets/javascripts/observability/constants.js
@@ -1,16 +1,32 @@
+import { __ } from '~/locale';
+
export const MESSAGE_EVENT_TYPE = Object.freeze({
GOUI_LOADED: 'GOUI_LOADED',
GOUI_ROUTE_UPDATE: 'GOUI_ROUTE_UPDATE',
});
export const OBSERVABILITY_ROUTES = Object.freeze({
- DASHBOARDS: '/groups/gitlab-org/-/observability/dashboards',
- EXPLORE: '/groups/gitlab-org/-/observability/explore',
- MANAGE: '/groups/gitlab-org/-/observability/manage',
+ DASHBOARDS: 'observability/dashboards',
+ EXPLORE: 'observability/explore',
+ MANAGE: 'observability/manage',
+});
+
+export const SKELETON_VARIANTS_BY_ROUTE = Object.freeze({
+ [OBSERVABILITY_ROUTES.DASHBOARDS]: 'dashboards',
+ [OBSERVABILITY_ROUTES.EXPLORE]: 'explore',
+ [OBSERVABILITY_ROUTES.MANAGE]: 'manage',
});
-export const SKELETON_VARIANT = Object.freeze({
- DASHBOARDS: 'dashboards',
- EXPLORE: 'explore',
- MANAGE: 'manage',
+export const SKELETON_STATE = Object.freeze({
+ ERROR: 'error',
+ VISIBLE: 'visible',
+ HIDDEN: 'hidden',
});
+
+export const DEFAULT_TIMERS = Object.freeze({
+ TIMEOUT_MS: 20000,
+ CONTENT_WAIT_MS: 500,
+});
+
+export const TIMEOUT_ERROR_LABEL = __('Unable to load the page');
+export const TIMEOUT_ERROR_MESSAGE = __('Reload the page to try again.');
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
index acf810257e6..38b601ac3ec 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
@@ -95,7 +95,7 @@ export default {
return formatDate(this.tag.createdAt, 'isoDate');
},
publishedTime() {
- return formatDate(this.tag.createdAt, 'hh:MM Z');
+ return formatDate(this.tag.createdAt, 'HH:MM:ss Z');
},
formattedRevision() {
// to be removed when API response is adjusted
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 23d8e97dd79..4f89d217623 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
@@ -12,7 +12,6 @@ import {
REMOVE_REPOSITORY_LABEL,
ROW_SCHEDULED_FOR_DELETION,
IMAGE_DELETE_SCHEDULED_STATUS,
- IMAGE_FAILED_DELETED_STATUS,
IMAGE_MIGRATING_STATE,
COPY_IMAGE_PATH_TITLE,
IMAGE_FULL_PATH_LABEL,
@@ -79,9 +78,6 @@ export default {
migrating() {
return this.item.migrationState === IMAGE_MIGRATING_STATE;
},
- failedDelete() {
- return this.item.status === IMAGE_FAILED_DELETED_STATUS;
- },
tagsCountText() {
return n__(
'ContainerRegistry|%{count} Tag',
@@ -99,9 +95,6 @@ export default {
}
return projectPath;
},
- routerLinkEvent() {
- return this.deleting ? '' : 'click';
- },
deleteButtonTooltipTitle() {
return this.migrating
? LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION
@@ -121,15 +114,7 @@ export default {
</script>
<template>
- <list-item
- v-gl-tooltip="{
- placement: 'left',
- disabled: !deleting,
- title: $options.i18n.ROW_SCHEDULED_FOR_DELETION,
- }"
- v-bind="$attrs"
- :disabled="deleting"
- >
+ <list-item v-bind="$attrs">
<template #left-primary>
<gl-button
v-if="!showFullPath"
@@ -143,12 +128,13 @@ export default {
:aria-label="$options.i18n.IMAGE_FULL_PATH_LABEL"
@click="hideButton"
/>
+ <span v-if="deleting" class="gl-text-gray-500">{{ imageName }}</span>
<router-link
+ v-else
ref="imageName"
class="gl-text-body gl-font-weight-bold"
data-testid="details-link"
data-qa-selector="registry_image_content"
- :event="routerLinkEvent"
:to="{ name: 'details', params: { id } }"
>
{{ imageName }}
@@ -163,21 +149,24 @@ export default {
</template>
<template #left-secondary>
<template v-if="!metadataLoading">
- <span class="gl-display-flex gl-align-items-center" data-testid="tags-count">
- <gl-icon name="tag" class="gl-mr-2" />
- <gl-sprintf :message="tagsCountText">
- <template #count>
- {{ item.tagsCount }}
- </template>
- </gl-sprintf>
- </span>
+ <span v-if="deleting">{{ $options.i18n.ROW_SCHEDULED_FOR_DELETION }}</span>
+ <template v-else>
+ <span class="gl-display-flex gl-align-items-center" data-testid="tags-count">
+ <gl-icon name="tag" class="gl-mr-2" />
+ <gl-sprintf :message="tagsCountText">
+ <template #count>
+ {{ item.tagsCount }}
+ </template>
+ </gl-sprintf>
+ </span>
- <cleanup-status
- v-if="item.expirationPolicyCleanupStatus"
- class="ml-2"
- :status="item.expirationPolicyCleanupStatus"
- :expiration-policy="expirationPolicy"
- />
+ <cleanup-status
+ v-if="item.expirationPolicyCleanupStatus"
+ class="gl-ml-2"
+ :status="item.expirationPolicyCleanupStatus"
+ :expiration-policy="expirationPolicy"
+ />
+ </template>
</template>
<div v-else class="gl-w-full">
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
index 020d78ad364..f2aa4916f48 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
@@ -53,7 +53,6 @@ export const TRACKING_ACTION_CLICK_SHOW_FULL_PATH = 'click_show_full_path';
// Parameters
export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED';
-export const IMAGE_FAILED_DELETED_STATUS = 'DELETE_FAILED';
export const IMAGE_MIGRATING_STATE = 'importing';
export const GRAPHQL_PAGE_SIZE = 10;
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 9b062024d03..850dca07a3f 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
@@ -20,7 +20,7 @@ export const apolloProvider = new VueApollo({
ContainerRepositoryDetails: {
fields: {
tags: {
- keyArgs: ['id'],
+ keyArgs: ['id', 'name', 'sort'],
merge: mergeVariables,
},
},
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_history.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_history.vue
index e5be98b87f7..06e4c38a179 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_history.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_history.vue
@@ -96,9 +96,9 @@ export default {
<history-item icon="commit" data-testid="first-pipeline-commit">
<gl-sprintf :message="$options.i18n.createdByCommitText">
<template #link>
- <gl-link :href="firstPipeline.project.commit_url"
- >#{{ truncate(firstPipeline.sha) }}</gl-link
- >
+ <gl-link :href="firstPipeline.project.commit_url">{{
+ truncate(firstPipeline.sha)
+ }}</gl-link>
</template>
<template #branch>
<strong>{{ firstPipeline.ref }}</strong>
@@ -147,7 +147,7 @@ export default {
>
<gl-sprintf :message="$options.i18n.combinedUpdateText">
<template #link>
- <gl-link :href="pipeline.project.commit_url">#{{ truncate(pipeline.sha) }}</gl-link>
+ <gl-link :href="pipeline.project.commit_url">{{ truncate(pipeline.sha) }}</gl-link>
</template>
<template #branch>
<strong>{{ pipeline.ref }}</strong>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue
index 2a1de2ae4a7..011a2668a8b 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue
@@ -55,6 +55,7 @@ export default {
:action-cancel="$options.modal.cancelAction"
:title="$options.i18n.DELETE_PACKAGES_MODAL_TITLE"
@primary="$emit('confirm')"
+ @cancel="$emit('cancel')"
>
<span>{{ description }}</span>
</gl-modal>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
index a1fc7563de1..663c361819e 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
@@ -159,9 +159,9 @@ export default {
<history-item icon="commit" data-testid="first-pipeline-commit">
<gl-sprintf :message="$options.i18n.createdByCommitText">
<template #link>
- <gl-link :href="firstPipeline.commitPath" @click="trackCommitClick"
- >#{{ truncate(firstPipeline.sha) }}</gl-link
- >
+ <gl-link :href="firstPipeline.commitPath" @click="trackCommitClick">{{
+ truncate(firstPipeline.sha)
+ }}</gl-link>
</template>
<template #branch>
<strong>{{ firstPipeline.ref }}</strong>
@@ -212,9 +212,9 @@ export default {
>
<gl-sprintf :message="$options.i18n.combinedUpdateText">
<template #link>
- <gl-link :href="pipeline.commitPath" @click="trackCommitClick"
- >#{{ truncate(pipeline.sha) }}</gl-link
- >
+ <gl-link :href="pipeline.commitPath" @click="trackCommitClick">{{
+ truncate(pipeline.sha)
+ }}</gl-link>
</template>
<template #branch>
<strong>{{ pipeline.ref }}</strong>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
index efc60c9c037..787f21d9419 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
@@ -1,13 +1,13 @@
<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';
+import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
export default {
components: {
VersionRow,
- GlKeysetPagination,
PackagesListLoader,
+ RegistryList,
},
props: {
versions: {
@@ -26,9 +26,6 @@ export default {
},
},
computed: {
- showPagination() {
- return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
- },
isListEmpty() {
return this.versions.length === 0;
},
@@ -42,16 +39,18 @@ export default {
</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>
+ <registry-list
+ :hidden-delete="true"
+ :is-loading="isLoading"
+ :items="versions"
+ :pagination="pageInfo"
+ @prev-page="$emit('prev-page')"
+ @next-page="$emit('next-page')"
+ >
+ <template #default="{ item }">
+ <version-row :package-entity="item" />
+ </template>
+ </registry-list>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue
index dd58f28a262..fdc6e75c932 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue
@@ -30,7 +30,7 @@ export default {
computed: {
pypiPipCommand() {
// eslint-disable-next-line @gitlab/require-i18n-strings
- return `pip install ${this.packageEntity.name} --extra-index-url ${this.packageEntity.pypiUrl}`;
+ return `pip install ${this.packageEntity.name} --index-url ${this.packageEntity.pypiUrl}`;
},
pypiSetupCommand() {
return `[gitlab]
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 ddcddf80c15..40bf7b7e143 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
@@ -5,10 +5,14 @@ import DeletePackageModal from '~/packages_and_registries/shared/components/dele
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 DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue';
import {
DELETE_PACKAGE_TRACKING_ACTION,
+ DELETE_PACKAGES_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
+ REQUEST_DELETE_PACKAGES_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
+ CANCEL_DELETE_PACKAGES_TRACKING_ACTION,
PACKAGE_ERROR_STATUS,
} from '~/packages_and_registries/package_registry/constants';
import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
@@ -18,6 +22,7 @@ export default {
name: 'PackagesList',
components: {
GlAlert,
+ DeleteModal,
DeletePackageModal,
PackagesListLoader,
PackagesListRow,
@@ -44,6 +49,7 @@ export default {
data() {
return {
itemToBeDeleted: null,
+ itemsToBeDeleted: [],
errorPackages: [],
};
},
@@ -92,7 +98,18 @@ export default {
this.setItemToBeDeleted(item);
return;
}
- this.$emit('delete', items);
+ this.itemsToBeDeleted = items;
+ this.track(REQUEST_DELETE_PACKAGES_TRACKING_ACTION);
+ this.$refs.deletePackagesModal.show();
+ },
+ deleteItemsConfirmation() {
+ this.$emit('delete', this.itemsToBeDeleted);
+ this.track(DELETE_PACKAGES_TRACKING_ACTION);
+ this.itemsToBeDeleted = [];
+ },
+ deleteItemsCanceled() {
+ this.track(CANCEL_DELETE_PACKAGES_TRACKING_ACTION);
+ this.itemsToBeDeleted = [];
},
deleteItemConfirmation() {
this.$emit('package:delete', this.itemToBeDeleted);
@@ -159,6 +176,13 @@ export default {
@ok="deleteItemConfirmation"
@cancel="deleteItemCanceled"
/>
+
+ <delete-modal
+ ref="deletePackagesModal"
+ :items-to-be-deleted="itemsToBeDeleted"
+ @confirm="deleteItemsConfirmation"
+ @cancel="deleteItemsCanceled"
+ />
</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 b731cd77e66..539b12bd6db 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,11 @@ 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_TRACKING_ACTION = 'delete_packages';
+export const REQUEST_DELETE_PACKAGES_TRACKING_ACTION = 'request_delete_packages';
+export const CANCEL_DELETE_PACKAGES_TRACKING_ACTION = 'cancel_delete_packages';
+
export const DELETE_PACKAGES_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting packages.',
);
@@ -184,9 +189,6 @@ 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/pages/list.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
index 8b5d51cb856..396429d60d8 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,20 +1,18 @@
<script>
-import { GlAlert, GlBanner, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
-import { createAlert, VARIANT_INFO } from '~/flash';
-import { getCookie, historyReplaceState, parseBoolean, setCookie } from '~/lib/utils/common_utils';
+import { GlAlert, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
+import { createAlert, VARIANT_INFO, VARIANT_SUCCESS, VARIANT_DANGER } from '~/flash';
+import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants';
import {
PROJECT_RESOURCE_TYPE,
GROUP_RESOURCE_TYPE,
GRAPHQL_PAGE_SIZE,
- 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';
@@ -22,31 +20,26 @@ import DeletePackage from '~/packages_and_registries/package_registry/components
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: {
@@ -121,15 +114,13 @@ export default {
historyReplaceState(cleanUrl);
}
},
- async confirmDelete() {
- const { itemsToBeDeleted } = this;
- this.itemsToBeDeleted = [];
+ async deletePackages(packageEntities) {
this.mutationLoading = true;
try {
const { data } = await this.$apollo.mutate({
mutation: destroyPackagesMutation,
variables: {
- ids: itemsToBeDeleted.map((i) => i.id),
+ ids: packageEntities.map((i) => i.id),
},
awaitRefetchQueries: true,
refetchQueries: [
@@ -144,30 +135,22 @@ export default {
throw new Error(data.destroyPackages.errors[0]);
}
this.showAlert({
- variant: 'success',
+ variant: VARIANT_SUCCESS,
message: DELETE_PACKAGES_SUCCESS_MESSAGE,
});
} catch {
this.showAlert({
- variant: 'danger',
+ variant: 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;
},
@@ -208,17 +191,11 @@ 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>
@@ -233,17 +210,6 @@ export default {
>
{{ 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 class="gl-mb-5" @update="handleSearchUpdate" />
@@ -261,7 +227,7 @@ export default {
@prev-page="fetchPreviousPage"
@next-page="fetchNextPage"
@package:delete="deletePackage"
- @delete="showDeletePackagesModal"
+ @delete="deletePackages"
>
<template #empty-state>
<gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
@@ -280,11 +246,5 @@ 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/shared/components/registry_list.vue b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
index cc345fda7e8..d07d0a7673f 100644
--- a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
@@ -87,13 +87,15 @@ export default {
<template>
<div>
- <div class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-align-items-center">
- <gl-form-checkbox v-if="!hiddenDelete" v-model="selectAll" class="gl-ml-2 gl-pt-2">
+ <div
+ v-if="!hiddenDelete"
+ class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-align-items-center"
+ >
+ <gl-form-checkbox v-model="selectAll" class="gl-ml-2 gl-pt-2">
<span class="gl-font-weight-bold">{{ title }}</span>
</gl-form-checkbox>
<gl-button
- v-if="!hiddenDelete"
:disabled="disableDeleteButton"
category="secondary"
variant="danger"
diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js
index a6e3a7dc08a..f1e92cf195a 100644
--- a/app/assets/javascripts/pages/admin/application_settings/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/index.js
@@ -1,4 +1,4 @@
-import initVariableList from '~/ci_variable_list';
+import initVariableList from '~/ci/ci_variable_list';
import projectSelect from '~/project_select';
import initSearchSettings from '~/search_settings';
import selfMonitor from '~/self_monitor';
diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
deleted file mode 100644
index 40348e0b18a..00000000000
--- a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import $ from 'jquery';
-import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
-import axios from '~/lib/utils/axios_utils';
-import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { __ } from '~/locale';
-
-export default () => {
- const $broadcastMessageTheme = $('.js-broadcast-message-theme');
- const $broadcastMessageType = $('.js-broadcast-message-type');
- const $broadcastBannerMessagePreview = $('.js-broadcast-banner-message-preview [role="alert"]');
- const $broadcastMessage = $('.js-broadcast-message-message');
- const $jsBroadcastMessagePreview = $('#broadcast-message-preview');
-
- const reloadPreview = function reloadPreview() {
- const previewPath = $broadcastMessage.data('previewPath');
- const message = $broadcastMessage.val();
- const type = $broadcastMessageType.val();
- const theme = $broadcastMessageTheme.val();
-
- axios
- .post(previewPath, {
- broadcast_message: {
- message,
- broadcast_type: type,
- theme,
- },
- })
- .then(({ data }) => {
- $jsBroadcastMessagePreview.html(data);
- })
- .catch(() =>
- createAlert({
- message: __('An error occurred while rendering preview broadcast message'),
- }),
- );
- };
-
- $broadcastMessageTheme.on('change', reloadPreview);
-
- $broadcastMessageType.on('change', () => {
- const $broadcastMessageColorFormGroup = $('.js-broadcast-message-background-color-form-group');
- const $broadcastMessageDismissableFormGroup = $('.js-broadcast-message-dismissable-form-group');
- const $broadcastNotificationMessagePreview = $('.js-broadcast-notification-message-preview');
-
- $broadcastMessageColorFormGroup.toggleClass('hidden');
- $broadcastMessageDismissableFormGroup.toggleClass('hidden');
- $broadcastBannerMessagePreview.toggleClass('hidden');
- $broadcastNotificationMessagePreview.toggleClass('hidden');
-
- reloadPreview();
- });
-
- $broadcastMessage.on(
- 'input',
- debounce(() => {
- reloadPreview();
- }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
- );
-};
diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/edit/index.js b/app/assets/javascripts/pages/admin/broadcast_messages/edit/index.js
index 25036984082..94cae500a1e 100644
--- a/app/assets/javascripts/pages/admin/broadcast_messages/edit/index.js
+++ b/app/assets/javascripts/pages/admin/broadcast_messages/edit/index.js
@@ -1,8 +1,3 @@
import initEditBroadcastMessage from '~/admin/broadcast_messages/edit';
-import initBroadcastMessagesForm from '../broadcast_message';
-if (gon.features.vueBroadcastMessages) {
- initEditBroadcastMessage();
-} else {
- initBroadcastMessagesForm();
-}
+initEditBroadcastMessage();
diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/index/index.js b/app/assets/javascripts/pages/admin/broadcast_messages/index/index.js
index 1f37df2b340..2662496be05 100644
--- a/app/assets/javascripts/pages/admin/broadcast_messages/index/index.js
+++ b/app/assets/javascripts/pages/admin/broadcast_messages/index/index.js
@@ -1,10 +1,3 @@
import initBroadcastMessages from '~/admin/broadcast_messages';
-import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
-import initBroadcastMessagesForm from '../broadcast_message';
-if (gon.features.vueBroadcastMessages) {
- initBroadcastMessages();
-} else {
- initBroadcastMessagesForm();
- initDeprecatedRemoveRowBehavior();
-}
+initBroadcastMessages();
diff --git a/app/assets/javascripts/pages/groups/boards/index.js b/app/assets/javascripts/pages/groups/boards/index.js
index bde0007ec6a..23f5b083589 100644
--- a/app/assets/javascripts/pages/groups/boards/index.js
+++ b/app/assets/javascripts/pages/groups/boards/index.js
@@ -1,7 +1,5 @@
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initBoards from '~/boards';
-import UsersSelect from '~/users_select';
-new UsersSelect(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
initBoards();
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index 58ca195d7b9..fb685247bd4 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -2,7 +2,7 @@ import { GROUP_BADGE } from '~/badges/constants';
import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import initFilePickers from '~/file_pickers';
import initTransferGroupForm from '~/groups/init_transfer_group_form';
-import groupsSelect from '~/groups_select';
+import { initGroupSelects } from '~/vue_shared/components/group_select/init_group_selects';
import { initCascadingSettingsLockPopovers } from '~/namespaces/cascading_settings';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import projectSelect from '~/project_select';
@@ -20,7 +20,7 @@ dirtySubmitFactory(
mountBadgeSettings(GROUP_BADGE);
// Initialize Subgroups selector
-groupsSelect();
+initGroupSelects();
projectSelect();
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 62d47cb49b8..ceda2c8fa17 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -11,7 +11,7 @@ import { groupLinkRequestFormatter } from '~/members/utils';
const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
const APP_OPTIONS = {
[MEMBER_TYPES.user]: {
- tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']),
+ tableFields: SHARED_FIELDS.concat(['source', 'activity']),
tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
tableSortableFields: [
'account',
diff --git a/app/assets/javascripts/pages/groups/observability/datasources/index.js b/app/assets/javascripts/pages/groups/observability/datasources/index.js
new file mode 100644
index 00000000000..c3b6ce6f99f
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/observability/datasources/index.js
@@ -0,0 +1,3 @@
+import ObservabilityApp from '~/observability';
+
+ObservabilityApp();
diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
index b1a1cc21764..184958bd189 100644
--- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
@@ -1,5 +1,5 @@
import initStaleRunnerCleanupSetting from 'ee_else_ce/group_settings/stale_runner_cleanup';
-import initVariableList from '~/ci_variable_list';
+import initVariableList from '~/ci/ci_variable_list';
import initSharedRunnersForm from '~/group_settings/mount_shared_runners';
import initSettingsPanels from '~/settings_panels';
import initDeployTokens from '~/deploy_tokens';
diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js
index 161fca83a58..53bceb3a6f0 100644
--- a/app/assets/javascripts/pages/groups/show/index.js
+++ b/app/assets/javascripts/pages/groups/show/index.js
@@ -1,7 +1,9 @@
import leaveByUrl from '~/namespaces/leave_by_url';
import { initGroupOverviewTabs } from '~/groups/init_overview_tabs';
+import initReadMore from '~/read_more';
import initGroupDetails from '../shared/group_details';
leaveByUrl('group');
initGroupDetails();
initGroupOverviewTabs();
+initReadMore();
diff --git a/app/assets/javascripts/pages/import/bitbucket/status/index.js b/app/assets/javascripts/pages/import/bitbucket/status/index.js
index 6e9c26bf930..0b0f222ab76 100644
--- a/app/assets/javascripts/pages/import/bitbucket/status/index.js
+++ b/app/assets/javascripts/pages/import/bitbucket/status/index.js
@@ -1,21 +1,6 @@
-import Vue from 'vue';
-import { initStoreFromElement, initPropsFromElement } from '~/import_entities/import_projects';
+import mountImportProjectsTable from '~/import_entities/import_projects';
import BitbucketStatusTable from '~/import_entities/import_projects/components/bitbucket_status_table.vue';
-function importBitBucket() {
- const mountElement = document.getElementById('import-projects-mount-element');
- if (!mountElement) return undefined;
+const mountElement = document.getElementById('import-projects-mount-element');
- const store = initStoreFromElement(mountElement);
- const attrs = initPropsFromElement(mountElement);
-
- return new Vue({
- el: mountElement,
- store,
- render(createElement) {
- return createElement(BitbucketStatusTable, { attrs });
- },
- });
-}
-
-importBitBucket();
+mountImportProjectsTable({ mountElement, Component: BitbucketStatusTable });
diff --git a/app/assets/javascripts/pages/import/bitbucket_server/status/index.js b/app/assets/javascripts/pages/import/bitbucket_server/status/index.js
index 90eb423c7a7..680ff0ddcde 100644
--- a/app/assets/javascripts/pages/import/bitbucket_server/status/index.js
+++ b/app/assets/javascripts/pages/import/bitbucket_server/status/index.js
@@ -1,24 +1,10 @@
-import Vue from 'vue';
-import { initStoreFromElement, initPropsFromElement } from '~/import_entities/import_projects';
-import BitbucketServerStatusTable from './components/bitbucket_server_status_table.vue';
-
-function BitbucketServerStatus() {
- const mountElement = document.getElementById('import-projects-mount-element');
- if (!mountElement) return undefined;
+import mountImportProjectsTable from '~/import_entities/import_projects';
- const store = initStoreFromElement(mountElement);
- const attrs = initPropsFromElement(mountElement);
- const { reconfigurePath } = mountElement.dataset;
-
- return new Vue({
- el: mountElement,
- store,
- render(createElement) {
- return createElement(BitbucketServerStatusTable, {
- attrs: { ...attrs, reconfigurePath },
- });
- },
- });
-}
+import BitbucketServerStatusTable from './components/bitbucket_server_status_table.vue';
-BitbucketServerStatus();
+const mountElement = document.getElementById('import-projects-mount-element');
+mountImportProjectsTable({
+ mountElement,
+ Component: BitbucketServerStatusTable,
+ extraProps: ({ reconfigurePath }) => ({ reconfigurePath }),
+});
diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
index 6feb4c2188f..3dcababb4fd 100644
--- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
+++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
@@ -1,5 +1,13 @@
<script>
-import { GlButton, GlEmptyState, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui';
+import {
+ GlButton,
+ GlEmptyState,
+ GlIcon,
+ GlLink,
+ GlLoadingIcon,
+ GlTableLite,
+ GlTooltipDirective as GlTooltip,
+} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { createAlert } from '~/flash';
@@ -34,15 +42,20 @@ export default {
components: {
GlButton,
GlEmptyState,
+ GlIcon,
GlLink,
GlLoadingIcon,
- GlTable,
+ GlTableLite,
PaginationBar,
ImportStatus,
TimeAgo,
LocalStorageSync,
},
+ directives: {
+ GlTooltip,
+ },
+
data() {
return {
loading: true,
@@ -58,12 +71,12 @@ export default {
fields: [
tableCell({
key: 'source_full_path',
- label: s__('BulkImport|Source group'),
+ label: s__('BulkImport|Source'),
thClass: `${DEFAULT_TH_CLASSES} gl-w-30p`,
}),
tableCell({
key: 'destination_name',
- label: s__('BulkImport|Destination group'),
+ label: s__('BulkImport|Destination'),
thClass: `${DEFAULT_TH_CLASSES} gl-w-40p`,
}),
tableCell({
@@ -113,12 +126,24 @@ export default {
}
},
- getDestinationUrl({ destination_name: name, destination_namespace: namespace }) {
- return [namespace, name].filter(Boolean).join('/');
+ getFullDestinationUrl(params) {
+ return joinPaths(gon.relative_url_root || '', '/', params.destination_full_path);
},
- getFullDestinationUrl(params) {
- return joinPaths(gon.relative_url_root || '', '/', this.getDestinationUrl(params));
+ getPresentationUrl(item) {
+ const suffix = item.entity_type === 'group' ? '/' : '';
+ return `${item.destination_full_path}${suffix}`;
+ },
+
+ getEntityTooltip(item) {
+ switch (item.entity_type) {
+ case 'project':
+ return __('Project');
+ case 'group':
+ return __('Group');
+ default:
+ return '';
+ }
},
},
@@ -134,26 +159,36 @@ export default {
>
<h1 class="gl-my-0 gl-py-4 gl-font-size-h1">
<img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" />
- {{ s__('BulkImport|Group import history') }}
+ {{ s__('BulkImport|GitLab Migration history') }}
</h1>
</div>
<gl-loading-icon v-if="loading" size="lg" class="gl-mt-5" />
<gl-empty-state
v-else-if="!hasHistoryItems"
:title="s__('BulkImport|No history is available')"
- :description="s__('BulkImport|Your imported groups will appear here.')"
+ :description="s__('BulkImport|Your imported groups and projects will appear here.')"
/>
<template v-else>
- <gl-table
+ <gl-table-lite
:fields="$options.fields"
:items="historyItems"
data-qa-selector="import_history_table"
class="gl-w-full"
>
<template #cell(destination_name)="{ item }">
- <gl-link :href="getFullDestinationUrl(item)" target="_blank">
- {{ getDestinationUrl(item) }}
- </gl-link>
+ <template v-if="item.destination_full_path">
+ <gl-icon
+ v-gl-tooltip
+ :name="item.entity_type"
+ :title="getEntityTooltip(item)"
+ :aria-label="getEntityTooltip(item)"
+ class="gl-text-gray-500"
+ />
+ <gl-link :href="getFullDestinationUrl(item)" target="_blank">
+ {{ getPresentationUrl(item) }}
+ </gl-link>
+ </template>
+ <gl-loading-icon v-else inline />
</template>
<template #cell(created_at)="{ value }">
<time-ago :time="value" />
@@ -171,7 +206,7 @@ export default {
<template #row-details="{ item }">
<pre><code>{{ item.failures }}</code></pre>
</template>
- </gl-table>
+ </gl-table-lite>
<pagination-bar
:page-info="pageInfo"
class="gl-m-0 gl-mt-3"
diff --git a/app/assets/javascripts/pages/import/fogbugz/status/index.js b/app/assets/javascripts/pages/import/fogbugz/status/index.js
index 4c427b72372..30ee468734d 100644
--- a/app/assets/javascripts/pages/import/fogbugz/status/index.js
+++ b/app/assets/javascripts/pages/import/fogbugz/status/index.js
@@ -2,4 +2,4 @@ import mountImportProjectsTable from '~/import_entities/import_projects';
const mountElement = document.getElementById('import-projects-mount-element');
-mountImportProjectsTable(mountElement);
+mountImportProjectsTable({ mountElement });
diff --git a/app/assets/javascripts/pages/import/gitea/status/index.js b/app/assets/javascripts/pages/import/gitea/status/index.js
index 4c427b72372..30ee468734d 100644
--- a/app/assets/javascripts/pages/import/gitea/status/index.js
+++ b/app/assets/javascripts/pages/import/gitea/status/index.js
@@ -2,4 +2,4 @@ import mountImportProjectsTable from '~/import_entities/import_projects';
const mountElement = document.getElementById('import-projects-mount-element');
-mountImportProjectsTable(mountElement);
+mountImportProjectsTable({ mountElement });
diff --git a/app/assets/javascripts/pages/import/github/status/index.js b/app/assets/javascripts/pages/import/github/status/index.js
index 4c427b72372..30ee468734d 100644
--- a/app/assets/javascripts/pages/import/github/status/index.js
+++ b/app/assets/javascripts/pages/import/github/status/index.js
@@ -2,4 +2,4 @@ import mountImportProjectsTable from '~/import_entities/import_projects';
const mountElement = document.getElementById('import-projects-mount-element');
-mountImportProjectsTable(mountElement);
+mountImportProjectsTable({ mountElement });
diff --git a/app/assets/javascripts/pages/import/gitlab/status/index.js b/app/assets/javascripts/pages/import/gitlab/status/index.js
index 4c427b72372..30ee468734d 100644
--- a/app/assets/javascripts/pages/import/gitlab/status/index.js
+++ b/app/assets/javascripts/pages/import/gitlab/status/index.js
@@ -2,4 +2,4 @@ import mountImportProjectsTable from '~/import_entities/import_projects';
const mountElement = document.getElementById('import-projects-mount-element');
-mountImportProjectsTable(mountElement);
+mountImportProjectsTable({ mountElement });
diff --git a/app/assets/javascripts/pages/import/manifest/status/index.js b/app/assets/javascripts/pages/import/manifest/status/index.js
index 4c427b72372..30ee468734d 100644
--- a/app/assets/javascripts/pages/import/manifest/status/index.js
+++ b/app/assets/javascripts/pages/import/manifest/status/index.js
@@ -2,4 +2,4 @@ import mountImportProjectsTable from '~/import_entities/import_projects';
const mountElement = document.getElementById('import-projects-mount-element');
-mountImportProjectsTable(mountElement);
+mountImportProjectsTable({ mountElement });
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index af0097b415c..46704d96552 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -78,3 +78,5 @@ if (filesContainer.length) {
loadAwardsHandler();
initCommitActions();
+
+syntaxHighlight([document.querySelector('.files')]);
diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js
index b74f7d1cf57..760bf3f7131 100644
--- a/app/assets/javascripts/pages/projects/compare/show/index.js
+++ b/app/assets/javascripts/pages/projects/compare/show/index.js
@@ -2,6 +2,7 @@ import Diff from '~/diff';
import GpgBadges from '~/gpg_badges';
import { initDiffStatsDropdown } from '~/init_diff_stats_dropdown';
import initCompareSelector from '~/projects/compare';
+import syntaxHighlight from '~/syntax_highlight';
initCompareSelector();
@@ -9,3 +10,5 @@ new Diff(); // eslint-disable-line no-new
const paddingTop = 16;
initDiffStatsDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop);
GpgBadges.fetch();
+
+syntaxHighlight([document.querySelector('.files')]);
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
index 91650003d4a..2028af8b8f0 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
@@ -57,6 +57,9 @@ export default {
visibilityHelpPath: {
default: '',
},
+ cancelPath: {
+ default: '',
+ },
projectFullPath: {
default: '',
},
@@ -97,7 +100,7 @@ export default {
required: false,
skipValidation: true,
}),
- visibility: initFormField({ value: this.getInitialVisibilityValue() }),
+ visibility: initFormField({ value: null }),
},
};
return {
@@ -106,8 +109,39 @@ export default {
};
},
computed: {
+ projectVisibilityLevel() {
+ return VISIBILITY_LEVELS_STRING_TO_INTEGER[this.projectVisibility];
+ },
+ namespaceVisibilityLevel() {
+ const visibility =
+ this.form.fields.namespace.value?.visibility || VISIBILITY_LEVEL_PUBLIC_STRING;
+ return VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility];
+ },
+ visibilityLevelCap() {
+ return Math.min(this.projectVisibilityLevel, this.namespaceVisibilityLevel);
+ },
+ restrictedVisibilityLevelsSet() {
+ return new Set(this.restrictedVisibilityLevels);
+ },
allowedVisibilityLevels() {
- return this.getAllowedVisibilityLevels();
+ const allowedLevels = Object.entries(VISIBILITY_LEVELS_STRING_TO_INTEGER).reduce(
+ (levels, [levelName, levelValue]) => {
+ if (
+ !this.restrictedVisibilityLevelsSet.has(levelValue) &&
+ levelValue <= this.visibilityLevelCap
+ ) {
+ levels.push(levelName);
+ }
+ return levels;
+ },
+ [],
+ );
+
+ if (!allowedLevels.length) {
+ return [VISIBILITY_LEVEL_PRIVATE_STRING];
+ }
+
+ return allowedLevels;
},
visibilityLevels() {
return [
@@ -143,13 +177,15 @@ export default {
this.form.fields.slug.value = kebabCase(newVal);
},
},
+ created() {
+ this.form.fields.visibility.value = this.getMaximumAllowedVisibilityLevel(
+ VISIBILITY_LEVEL_PUBLIC_STRING,
+ );
+ },
methods: {
isVisibilityLevelDisabled(visibility) {
return !this.allowedVisibilityLevels.includes(visibility);
},
- getInitialVisibilityValue() {
- return this.getMaximumAllowedVisibilityLevel(this.projectVisibility);
- },
setNamespace(namespace) {
this.form.fields.namespace.value = namespace;
this.form.fields.namespace.state = true;
@@ -157,42 +193,8 @@ export default {
this.form.fields.visibility.value,
);
},
- getProjectVisibilityLevel() {
- return VISIBILITY_LEVELS_STRING_TO_INTEGER[this.projectVisibility];
- },
- getNamespaceVisibilityLevel() {
- const visibility =
- this.form?.fields?.namespace?.value?.visibility || VISIBILITY_LEVEL_PUBLIC_STRING;
- return VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility];
- },
- getVisibilityLevelCap() {
- return Math.min(this.getProjectVisibilityLevel(), this.getNamespaceVisibilityLevel());
- },
- getRestrictedVisibilityLevelsSet() {
- return new Set(this.restrictedVisibilityLevels);
- },
- getAllowedVisibilityLevels() {
- const allowedLevels = Object.entries(VISIBILITY_LEVELS_STRING_TO_INTEGER).reduce(
- (levels, [levelName, levelValue]) => {
- if (
- !this.getRestrictedVisibilityLevelsSet().has(levelValue) &&
- levelValue <= this.getVisibilityLevelCap()
- ) {
- levels.push(levelName);
- }
- return levels;
- },
- [],
- );
-
- if (!allowedLevels.length) {
- return [VISIBILITY_LEVEL_PRIVATE_STRING];
- }
-
- return allowedLevels;
- },
getMaximumAllowedVisibilityLevel(visibility) {
- const allowedVisibilities = this.getAllowedVisibilityLevels().map(
+ const allowedVisibilities = this.allowedVisibilityLevels.map(
(s) => VISIBILITY_LEVELS_STRING_TO_INTEGER[s],
);
const current = VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility];
@@ -373,7 +375,7 @@ export default {
class="gl-mr-3"
data-testid="cancel-button"
:disabled="isSaving"
- :href="projectFullPath"
+ :href="cancelPath"
>
{{ s__('ForkProject|Cancel') }}
</gl-button>
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
index 00e0649deed..5e0c5735bc0 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
@@ -1,14 +1,5 @@
<script>
-import {
- GlButton,
- GlButtonGroup,
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlDropdownSectionHeader,
- GlSearchBoxByType,
- GlTruncate,
-} from '@gitlab/ui';
+import { GlButton, GlButtonGroup, GlCollapsibleListbox } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
import { s__ } from '~/locale';
@@ -20,12 +11,7 @@ export default {
components: {
GlButton,
GlButtonGroup,
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlDropdownSectionHeader,
- GlSearchBoxByType,
- GlTruncate,
+ GlCollapsibleListbox,
},
apollo: {
project: {
@@ -61,24 +47,25 @@ export default {
};
},
computed: {
+ loading() {
+ return this.$apollo.queries.project.loading;
+ },
rootUrl() {
return `${gon.gitlab_url}/`;
},
namespaces() {
return this.project.forkTargets?.nodes || [];
},
- hasMatches() {
- return this.namespaces.length;
- },
dropdownText() {
return this.selectedNamespace?.fullPath || s__('ForkProject|Select a namespace');
},
+ namespaceItems() {
+ return this.namespaces?.map(({ id, fullPath }) => ({ value: id, text: fullPath }));
+ },
},
methods: {
- handleDropdownShown() {
- this.$refs.search.focusInput();
- },
- setNamespace(namespace) {
+ setNamespace(namespaceId) {
+ const namespace = this.namespaces.find(({ id }) => id === namespaceId);
const id = getIdFromGraphQLId(namespace.id);
this.$emit('select', {
@@ -89,6 +76,9 @@ export default {
this.selectedNamespace = { id, fullPath: namespace.fullPath };
},
+ searchNamespaces(search) {
+ this.search = search;
+ },
},
};
</script>
@@ -98,39 +88,19 @@ export default {
<gl-button class="gl-text-truncate gl-flex-grow-0! gl-max-w-34" label :title="rootUrl">{{
rootUrl
}}</gl-button>
-
- <gl-dropdown
+ <gl-collapsible-listbox
class="gl-flex-grow-1"
- toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20"
data-qa-selector="select_namespace_dropdown"
data-testid="select_namespace_dropdown"
- no-flip
- @shown="handleDropdownShown"
- >
- <template #button-text>
- <gl-truncate :text="dropdownText" position="start" with-tooltip />
- </template>
- <gl-search-box-by-type
- ref="search"
- v-model.trim="search"
- :is-loading="$apollo.queries.project.loading"
- data-qa-selector="select_namespace_dropdown_search_field"
- data-testid="select_namespace_dropdown_search_field"
- />
- <template v-if="!$apollo.queries.project.loading">
- <template v-if="hasMatches">
- <gl-dropdown-section-header>{{ __('Namespaces') }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="namespace of namespaces"
- :key="namespace.id"
- data-qa-selector="select_namespace_dropdown_item"
- @click="setNamespace(namespace)"
- >
- {{ namespace.fullPath }}
- </gl-dropdown-item>
- </template>
- <gl-dropdown-text v-else>{{ __('No matches found') }}</gl-dropdown-text>
- </template>
- </gl-dropdown>
+ :items="namespaceItems"
+ :header-text="__('Namespaces')"
+ :no-results-text="__('No matches found')"
+ :searchable="true"
+ :searching="loading"
+ toggle-class="gl-flex-direction-column gl-align-items-stretch!"
+ :toggle-text="dropdownText"
+ @search="searchNamespaces"
+ @select="setNamespace"
+ />
</gl-button-group>
</template>
diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js
index d3a5ce5390f..a31b8b1a1f4 100644
--- a/app/assets/javascripts/pages/projects/forks/new/index.js
+++ b/app/assets/javascripts/pages/projects/forks/new/index.js
@@ -11,6 +11,7 @@ const {
newGroupPath,
projectFullPath,
visibilityHelpPath,
+ cancelPath,
projectId,
projectName,
projectPath,
@@ -30,6 +31,7 @@ new Vue({
provide: {
newGroupPath,
visibilityHelpPath,
+ cancelPath,
endpoint,
projectFullPath,
projectId,
diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js
index 7380055cbbf..37cf345fe77 100644
--- a/app/assets/javascripts/pages/projects/index.js
+++ b/app/assets/javascripts/pages/projects/index.js
@@ -1,9 +1,7 @@
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initTerraformNotification from '~/projects/terraform_notification';
-import { initSidebarTracking } from '../shared/nav/sidebar_tracking';
import Project from './project';
new Project(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
-initSidebarTracking();
initTerraformNotification();
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 3717d8027c4..d9b0dbbb9b0 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
@@ -22,7 +22,7 @@ export default {
GlTooltip,
},
i18n: {
- contactAdmin: s__('LearnGitlab|Contact your administrator to start a free Ultimate trial.'),
+ contactAdmin: s__('LearnGitlab|Contact your administrator to enable this action.'),
viewAdminList: s__('LearnGitlab|View administrator list'),
watchHow: __('Watch how'),
},
@@ -50,6 +50,9 @@ export default {
openInNewTab() {
return ACTION_LABELS[this.action]?.openInNewTab === true || this.value.openInNewTab === true;
},
+ popoverText() {
+ return this.value.message || this.$options.i18n.contactAdmin;
+ },
},
methods: {
openModal() {
@@ -101,7 +104,7 @@ export default {
category="tertiary"
icon="question-o"
class="ml-auto"
- :aria-label="$options.i18n.contactAdmin"
+ :aria-label="popoverText"
size="small"
data-testid="contact-admin-popover-trigger"
/>
@@ -111,7 +114,7 @@ export default {
triggers="hover focus"
data-testid="contact-admin-popover"
>
- <p>{{ $options.i18n.contactAdmin }}</p>
+ <p>{{ popoverText }}</p>
<gl-link
:href="value.url"
class="font-size-inherit"
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 a4e3ddfc506..d4734b8842d 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
@@ -4,14 +4,12 @@ import $ from 'jquery';
import IssuableForm from 'ee_else_ce/issuable/issuable_form';
import IssuableLabelSelector from '~/issuable/issuable_label_selector';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
-import Diff from '~/diff';
import GLForm from '~/gl_form';
import LabelsSelect from '~/labels/labels_select';
import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar';
export default () => {
- new Diff();
new ShortcutsNavigation();
new GLForm($('.merge-request-form'));
new IssuableForm($('.merge-request-form'));
diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
index 568bf19b55e..f0a955e5360 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -1,5 +1,7 @@
import initNotesApp from '~/mr_notes/init_notes';
+import { initReportAbuse } from '~/projects/merge_requests';
import { initMrPage } from '../page';
initMrPage();
initNotesApp();
+initReportAbuse();
diff --git a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
index 97e436920c7..6947b15dcbe 100644
--- a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
+++ b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import MlExperiment from '~/ml/experiment_tracking/components/ml_experiment.vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
const initShowExperiment = () => {
const element = document.querySelector('#js-show-ml-experiment');
@@ -13,6 +14,7 @@ const initShowExperiment = () => {
const candidates = JSON.parse(element.dataset.candidates);
const metricNames = JSON.parse(element.dataset.metrics);
const paramNames = JSON.parse(element.dataset.params);
+ const pagination = convertObjectPropsToCamelCase(JSON.parse(element.dataset.pagination));
// eslint-disable-next-line no-new
new Vue({
@@ -21,6 +23,7 @@ const initShowExperiment = () => {
candidates,
metricNames,
paramNames,
+ pagination,
},
render(h) {
return h(MlExperiment);
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 27610df482d..4bdbb70d942 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
@@ -67,10 +67,9 @@ function initTakeownershipModal() {
});
}
-initPipelineSchedulesCallout();
-
if (gon.features?.pipelineSchedulesVue) {
initPipelineSchedulesApp();
} else {
+ initPipelineSchedulesCallout();
initTakeownershipModal();
}
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
index fd8b1a6290f..242c5a1a97b 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -54,7 +54,7 @@ export default {
inputNameAttribute: 'schedule[cron]',
radioValue: this.initialCronInterval ? KEY_CUSTOM : KEY_EVERY_DAY,
cronInterval: this.initialCronInterval,
- cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron',
+ cronSyntaxUrl: 'https://docs.gitlab.com/ee/topics/cron/',
};
},
computed: {
@@ -95,7 +95,7 @@ export default {
},
{
value: KEY_CUSTOM,
- text: s__('PipelineScheduleIntervalPattern|Custom (%{linkStart}Cron syntax%{linkEnd})'),
+ text: s__('PipelineScheduleIntervalPattern|Custom (%{linkStart}Learn more.%{linkEnd})'),
link: this.cronSyntaxUrl,
},
];
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 abd17efc498..8440d0e77cd 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
@@ -3,7 +3,7 @@ import Vue from 'vue';
import { __ } from '~/locale';
import RefSelector from '~/ref/components/ref_selector.vue';
import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
-import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list';
+import setupNativeFormVariableList from '~/ci/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';
diff --git a/app/assets/javascripts/pages/projects/pipelines/new/index.js b/app/assets/javascripts/pages/projects/pipelines/new/index.js
index e1f71965853..9b94b8ba96b 100644
--- a/app/assets/javascripts/pages/projects/pipelines/new/index.js
+++ b/app/assets/javascripts/pages/projects/pipelines/new/index.js
@@ -1,3 +1,3 @@
-import initNewPipelineForm from '~/pipeline_new/index';
+import initNewPipelineForm from '~/ci/pipeline_new/index';
initNewPipelineForm();
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index 9a7fd74fd8c..2fd372a45b8 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -20,7 +20,7 @@ initImportProjectMembersTrigger();
const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-project-members-list-app'), {
[MEMBER_TYPES.user]: {
- tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']),
+ tableFields: SHARED_FIELDS.concat(['source', 'activity']),
tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
tableSortableFields: [
'account',
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index 8909ff1f221..895c7d0a18e 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -1,7 +1,7 @@
import initArtifactsSettings from '~/artifacts_settings';
import SecretValues from '~/behaviors/secret_values';
import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers';
-import initVariableList from '~/ci_variable_list';
+import initVariableList from '~/ci/ci_variable_list';
import initDeployFreeze from '~/deploy_freeze';
import registrySettingsApp from '~/packages_and_registries/settings/project/registry_settings_bundle';
import { initRunnerAwsDeployments } from '~/pages/shared/mount_runner_aws_deployments';
@@ -11,6 +11,7 @@ import initSettingsPanels from '~/settings_panels';
import { initTokenAccess } from '~/token_access';
import { initCiSecureFiles } from '~/ci_secure_files';
import initDeployTokens from '~/deploy_tokens';
+import { initProjectRunners } from '~/ci/runner/project_runners';
// Initialize expandable settings panels
initSettingsPanels();
@@ -37,11 +38,13 @@ document.querySelector('.js-toggle-extra-settings').addEventListener('click', (e
registrySettingsApp();
initDeployTokens();
initDeployFreeze();
-
initSettingsPipelinesTriggers();
initArtifactsSettings();
+
+initProjectRunners();
initSharedRunnersToggle();
initInstallRunner();
initRunnerAwsDeployments();
+
initTokenAccess();
initCiSecureFiles();
diff --git a/app/assets/javascripts/pages/projects/settings/merge_requests/index.js b/app/assets/javascripts/pages/projects/settings/merge_requests/index.js
index 0f7ede8ed42..40741be5f53 100644
--- a/app/assets/javascripts/pages/projects/settings/merge_requests/index.js
+++ b/app/assets/javascripts/pages/projects/settings/merge_requests/index.js
@@ -1,7 +1,4 @@
-import groupsSelect from '~/groups_select';
import UserCallout from '~/user_callout';
-groupsSelect();
-
// eslint-disable-next-line no-new
new UserCallout({ className: 'js-mr-approval-callout' });
diff --git a/app/assets/javascripts/pages/projects/usage_quotas/index.js b/app/assets/javascripts/pages/projects/usage_quotas/index.js
new file mode 100644
index 00000000000..885b8ca8e12
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/usage_quotas/index.js
@@ -0,0 +1,9 @@
+import initProjectStorage from '~/usage_quotas/storage/init_project_storage';
+import initSearchSettings from '~/search_settings';
+
+const initVueApp = () => {
+ initProjectStorage('js-project-storage-count-app');
+};
+
+initVueApp();
+initSearchSettings();
diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js
index 5cbb7a06bc1..30c351359e4 100644
--- a/app/assets/javascripts/pages/users/index.js
+++ b/app/assets/javascripts/pages/users/index.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import { setCookie } from '~/lib/utils/common_utils';
import UserCallout from '~/user_callout';
+import { initReportAbuse } from '~/users/profile';
import UserTabs from './user_tabs';
function initUserProfile(action) {
@@ -19,3 +20,4 @@ const page = $('body').attr('data-page');
const action = page.split(':')[1];
initUserProfile(action);
new UserCallout(); // eslint-disable-line no-new
+initReportAbuse();
diff --git a/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue b/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue
deleted file mode 100644
index d35d2010150..00000000000
--- a/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue
+++ /dev/null
@@ -1,114 +0,0 @@
-<script>
-import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui';
-import { debounce } from 'lodash';
-import axios from '~/lib/utils/axios_utils';
-import { BRANCH_REF_TYPE, TAG_REF_TYPE, DEBOUNCE_REFS_SEARCH_MS } from '../constants';
-import formatRefs from '../utils/format_refs';
-
-export default {
- components: {
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlSearchBoxByType,
- },
- inject: ['projectRefsEndpoint'],
- props: {
- value: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- },
- data() {
- return {
- isLoading: false,
- searchTerm: '',
- branches: [],
- tags: [],
- };
- },
- computed: {
- lowerCasedSearchTerm() {
- return this.searchTerm.toLowerCase();
- },
- refShortName() {
- return this.value.shortName;
- },
- hasTags() {
- return this.tags.length > 0;
- },
- },
- watch: {
- searchTerm() {
- this.debouncedLoadRefs();
- },
- },
- methods: {
- loadRefs() {
- this.isLoading = true;
-
- axios
- .get(this.projectRefsEndpoint, {
- params: {
- search: this.lowerCasedSearchTerm,
- },
- })
- .then(({ data }) => {
- // Note: These keys are uppercase in API
- const { Branches = [], Tags = [] } = data;
-
- this.branches = formatRefs(Branches, BRANCH_REF_TYPE);
- this.tags = formatRefs(Tags, TAG_REF_TYPE);
- })
- .catch((e) => {
- this.$emit('loadingError', e);
- })
- .finally(() => {
- this.isLoading = false;
- });
- },
- debouncedLoadRefs: debounce(function debouncedLoadRefs() {
- this.loadRefs();
- }, DEBOUNCE_REFS_SEARCH_MS),
- setRefSelected(ref) {
- this.$emit('input', ref);
- },
- isSelected(ref) {
- return ref.fullName === this.value.fullName;
- },
- },
-};
-</script>
-<template>
- <gl-dropdown :text="refShortName" block data-testid="ref-select" @show.once="loadRefs">
- <gl-search-box-by-type
- v-model.trim="searchTerm"
- :is-loading="isLoading"
- :placeholder="__('Search refs')"
- data-testid="search-refs"
- />
- <gl-dropdown-section-header>{{ __('Branches') }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="branch in branches"
- :key="branch.fullName"
- class="gl-font-monospace"
- is-check-item
- :is-checked="isSelected(branch)"
- @click="setRefSelected(branch)"
- >
- {{ branch.shortName }}
- </gl-dropdown-item>
- <gl-dropdown-section-header v-if="hasTags">{{ __('Tags') }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="tag in tags"
- :key="tag.fullName"
- class="gl-font-monospace"
- is-check-item
- :is-checked="isSelected(tag)"
- @click="setRefSelected(tag)"
- >
- {{ tag.shortName }}
- </gl-dropdown-item>
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/pipeline_new/utils/format_refs.js b/app/assets/javascripts/pipeline_new/utils/format_refs.js
deleted file mode 100644
index f0fbc5ed7b6..00000000000
--- a/app/assets/javascripts/pipeline_new/utils/format_refs.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { BRANCH_REF_TYPE, TAG_REF_TYPE } from '../constants';
-
-export default (refs, type) => {
- let fullName;
-
- return refs.map((ref) => {
- if (type === BRANCH_REF_TYPE) {
- fullName = `refs/heads/${ref}`;
- } else if (type === TAG_REF_TYPE) {
- fullName = `refs/tags/${ref}`;
- }
-
- return {
- shortName: ref,
- fullName,
- };
- });
-};
diff --git a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
index adeb4ae598b..ab837d04d9a 100644
--- a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
@@ -6,6 +6,7 @@ import { merge } from '~/lib/utils/yaml';
import { __ } from '~/locale';
import { isValidStepSeq } from '~/pipeline_wizard/validators';
import Tracking from '~/tracking';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import YamlEditor from './editor.vue';
import WizardStep from './step.vue';
import CommitStep from './commit.vue';
@@ -28,7 +29,7 @@ export default {
WizardStep,
CommitStep,
},
- mixins: [trackingMixin],
+ mixins: [trackingMixin, glFeatureFlagsMixin()],
props: {
steps: {
type: Object,
@@ -91,6 +92,11 @@ export default {
category: `pipeline_wizard:${this.templateId}`,
};
},
+ trackingExtraData() {
+ return {
+ features: this.glFeatures,
+ };
+ },
},
watch: {
isLastStep(value) {
@@ -125,6 +131,7 @@ export default {
extra: {
fromStep: this.currentStepIndex + 1,
toStep: this.currentStepIndex,
+ ...this.trackingExtraData,
},
});
},
@@ -136,6 +143,7 @@ export default {
extra: {
fromStep: this.currentStepIndex - 1,
toStep: this.currentStepIndex,
+ ...this.trackingExtraData,
},
});
},
@@ -144,6 +152,7 @@ export default {
this.track('click_button', {
label: 'pipeline_wizard_commit',
property: 'commit',
+ extra: this.trackingExtraData,
});
},
onEditorTouched() {
@@ -151,6 +160,7 @@ export default {
label: 'pipeline_wizard_editor_interaction',
extra: {
currentStep: this.currentStepIndex,
+ ...this.trackingExtraData,
},
});
},
diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
index c56537f4039..041b62e02ec 100644
--- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
@@ -4,7 +4,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html';
import { __, s__ } from '~/locale';
import { createAlert } from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility';
-import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
+import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import RetryFailedJobMutation from '../../graphql/mutations/retry_failed_job.mutation.graphql';
import { DEFAULT_FIELDS } from '../../constants';
@@ -12,7 +12,7 @@ export default {
fields: DEFAULT_FIELDS,
retry: __('Retry'),
components: {
- CiBadge,
+ CiBadgeLink,
GlButton,
GlLink,
GlTableLite,
@@ -72,7 +72,7 @@ export default {
<div
class="gl-display-flex gl-align-items-center gl-lg-justify-content-start gl-justify-content-end"
>
- <ci-badge :status="item.detailedStatus" :show-text="false" class="gl-mr-3" />
+ <ci-badge-link :status="item.detailedStatus" :show-text="false" class="gl-mr-3" />
<div class="gl-text-truncate">
<gl-link
:href="item.detailedStatus.detailsPath"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index 30528ce8d17..c498f12d5c7 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDropdownItem, GlEmptyState, GlIcon, GlLoadingIcon } from '@gitlab/ui';
+import { GlEmptyState, GlIcon, GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui';
import { isEqual } from 'lodash';
import { createAlert, VARIANT_INFO, VARIANT_WARNING } from '~/flash';
import { getParameterByName } from '~/lib/utils/url_utility';
@@ -26,8 +26,7 @@ export default {
PipelineKeyOptions,
components: {
EmptyState,
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
GlEmptyState,
GlIcon,
GlLoadingIcon,
@@ -315,7 +314,7 @@ export default {
this.updateContent(this.requestData);
},
changeVisibilityPipelineID(val) {
- this.selectedPipelineKeyOption = val;
+ this.selectedPipelineKeyOption = PipelineKeyOptions.find((e) => val === e.value);
},
},
};
@@ -355,21 +354,12 @@ export default {
:params="validatedParams"
@filterPipelines="filterPipelines"
/>
- <gl-dropdown
- class="gl-display-flex"
- :text="selectedPipelineKeyOption.text"
- data-testid="pipeline-key-dropdown"
- >
- <gl-dropdown-item
- v-for="(val, index) in $options.PipelineKeyOptions"
- :key="index"
- :is-checked="selectedPipelineKeyOption.key === val.key"
- is-check-item
- @click="changeVisibilityPipelineID(val)"
- >
- {{ val.text }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-collapsible-listbox
+ data-testid="pipeline-key-collapsible-box"
+ :toggle-text="selectedPipelineKeyOption.text"
+ :items="$options.PipelineKeyOptions"
+ @select="changeVisibilityPipelineID"
+ />
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
index 936ae4da1ec..00ab8a25ca1 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
@@ -1,12 +1,12 @@
<script>
import { CHILD_VIEW, TRACKING_CATEGORIES } from '~/pipelines/constants';
-import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
+import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import Tracking from '~/tracking';
import PipelinesTimeago from './time_ago.vue';
export default {
components: {
- CiBadge,
+ CiBadgeLink,
PipelinesTimeago,
},
mixins: [Tracking.mixin()],
@@ -38,14 +38,13 @@ export default {
<template>
<div>
- <ci-badge
+ <ci-badge-link
class="gl-mb-3"
:status="pipelineStatus"
:show-text="!isChildView"
- :icon-classes="'gl-vertical-align-middle!'"
data-qa-selector="pipeline_commit_status"
@ciStatusBadgeClick="trackClick"
/>
- <pipelines-timeago class="gl-mt-3" :pipeline="pipeline" />
+ <pipelines-timeago :pipeline="pipeline" />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
index 346f5735576..ed32d643c0e 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -161,7 +161,7 @@ export default {
<pipeline-url
:pipeline="item"
:pipeline-schedule-url="pipelineScheduleUrl"
- :pipeline-key="pipelineKeyOption.key"
+ :pipeline-key="pipelineKeyOption.value"
/>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
index cd44c998074..960af030421 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -19,53 +19,51 @@ export default {
duration() {
return this.pipeline?.details?.duration;
},
- finishedTime() {
- return this.pipeline?.details?.finished_at;
- },
- skipped() {
- return this.pipeline?.details?.status?.label === 'skipped';
- },
- stuck() {
- return this.pipeline.flags.stuck;
- },
durationFormatted() {
return durationTimeFormatted(this.duration);
},
+ finishedTime() {
+ return this.pipeline?.details?.finished_at;
+ },
showInProgress() {
return !this.duration && !this.finishedTime && !this.skipped;
},
showSkipped() {
return !this.duration && !this.finishedTime && this.skipped;
},
+ skipped() {
+ return this.pipeline?.details?.status?.label === 'skipped';
+ },
+ stuck() {
+ return this.pipeline.flags.stuck;
+ },
},
};
</script>
<template>
- <div class="gl-display-block">
- <span v-if="showInProgress" data-testid="pipeline-in-progress">
+ <div class="gl-display-flex gl-flex-direction-column time-ago">
+ <span
+ v-if="showInProgress"
+ class="gl-display-inline-flex gl-align-items-center"
+ data-testid="pipeline-in-progress"
+ >
<gl-icon v-if="stuck" name="warning" class="gl-mr-2" :size="12" data-testid="warning-icon" />
- <gl-icon
- v-else
- name="hourglass"
- class="gl-vertical-align-baseline! gl-mr-2"
- :size="12"
- data-testid="hourglass-icon"
- />
+ <gl-icon v-else name="hourglass" class="gl-mr-2" :size="12" data-testid="hourglass-icon" />
{{ s__('Pipeline|In progress') }}
</span>
<span v-if="showSkipped" data-testid="pipeline-skipped">
- <gl-icon name="status_skipped_borderless" class="gl-mr-2" :size="16" />
+ <gl-icon name="status_skipped_borderless" />
{{ s__('Pipeline|Skipped') }}
</span>
- <p v-if="duration" class="duration">
- <gl-icon name="timer" class="gl-vertical-align-baseline!" :size="12" />
+ <p v-if="duration" class="duration gl-display-inline-flex gl-align-items-center">
+ <gl-icon name="timer" class="gl-mr-2" :size="12" />
{{ durationFormatted }}
</p>
- <p v-if="finishedTime" class="finished-at d-none d-md-block">
- <gl-icon name="calendar" class="gl-vertical-align-baseline!" :size="12" />
+ <p v-if="finishedTime" class="finished-at gl-display-inline-flex gl-align-items-center">
+ <gl-icon name="calendar" class="gl-mr-2" :size="12" />
<time
v-gl-tooltip
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index ed8ec614304..2f37f90e625 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -71,12 +71,12 @@ export const PipelineKeyOptions = [
{
text: __('Show Pipeline ID'),
label: __('Pipeline ID'),
- key: 'id',
+ value: 'id',
},
{
text: __('Show Pipeline IID'),
label: __('Pipeline IID'),
- key: 'iid',
+ value: 'iid',
},
];
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
index 07551c2342f..e6770b71113 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
@@ -2,7 +2,7 @@ import Visibility from 'visibilityjs';
import { createAlert } from '~/flash';
import { helpPagePath } from '~/helpers/help_page_helper';
import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
-import httpStatusCodes from '~/lib/utils/http_status';
+import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status';
import Poll from '~/lib/utils/poll';
import { __ } from '~/locale';
import { validateParams } from '~/pipelines/utils';
@@ -196,7 +196,7 @@ export default {
this.updateTable();
})
.catch((e) => {
- const unauthorized = e.response.status === httpStatusCodes.UNAUTHORIZED;
+ const unauthorized = e.response.status === HTTP_STATUS_UNAUTHORIZED;
let errorMessage = __(
'An error occurred while trying to run a new pipeline for this merge request.',
);
diff --git a/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js b/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js
index 6520e68d41c..8e4d42a42c6 100644
--- a/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js
+++ b/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js
@@ -1,7 +1,10 @@
import Vue from 'vue';
+import { initListboxInputs } from '~/vue_shared/components/listbox_input/init_listbox_inputs';
import ProfilePreferences from './components/profile_preferences.vue';
export default () => {
+ initListboxInputs();
+
const el = document.querySelector('#js-profile-preferences-app');
const formEl = document.querySelector('#profile-preferences-form');
const shouldParse = ['integrationViews', 'themes', 'userFields'];
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index 93bc203d391..c031c5e5e8e 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -30,10 +30,6 @@ export default class Profile {
bindEvents() {
$('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm);
- $('.js-group-notification-email').on('change', this.submitForm);
- $('#user_notification_email').on('select2-selecting', (event) => {
- setTimeout(this.submitForm.bind(event.currentTarget));
- });
$('#user_email_opted_in').on('change', this.submitForm);
$('#user_notified_of_own_activity').on('change', this.submitForm);
this.form.on('submit', this.onSubmitForm);
diff --git a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
index a037e721677..a1fc3f1a731 100644
--- a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
@@ -1,11 +1,5 @@
<script>
-import {
- GlDropdown,
- GlSearchBoxByType,
- GlDropdownItem,
- GlDropdownText,
- GlLoadingIcon,
-} from '@gitlab/ui';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import {
I18N_NO_RESULTS_MESSAGE,
@@ -16,11 +10,7 @@ import {
export default {
name: 'BranchesDropdown',
components: {
- GlDropdown,
- GlSearchBoxByType,
- GlDropdownItem,
- GlDropdownText,
- GlLoadingIcon,
+ GlCollapsibleListbox,
},
props: {
value: {
@@ -46,13 +36,16 @@ export default {
},
computed: {
...mapGetters(['joinedBranches']),
- ...mapState(['isFetching', 'branch', 'branches']),
+ ...mapState(['isFetching']),
filteredResults() {
const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
return this.joinedBranches.filter((resultString) =>
resultString.toLowerCase().includes(lowerCasedSearchTerm),
);
},
+ listboxItems() {
+ return this.filteredResults.map((value) => ({ value, text: value }));
+ },
},
watch: {
// Parent component can set the branch value (e.g. when the user selects a different project)
@@ -68,10 +61,6 @@ export default {
...mapActions(['fetchBranches']),
selectBranch(branch) {
this.$emit('selectBranch', branch);
- this.searchTerm = branch; // enables isSelected to work as expected
- },
- isSelected(selectedBranch) {
- return selectedBranch === this.branch;
},
searchTermChanged(value) {
this.searchTerm = value;
@@ -81,36 +70,16 @@ export default {
};
</script>
<template>
- <gl-dropdown :text="value" :header-text="$options.i18n.branchHeaderTitle">
- <gl-search-box-by-type
- :value="searchTerm"
- trim
- autocomplete="off"
- :debounce="250"
- :placeholder="$options.i18n.branchSearchPlaceholder"
- data-testid="dropdown-search-box"
- @input="searchTermChanged"
- />
- <gl-dropdown-item
- v-for="branch in filteredResults"
- v-show="!isFetching"
- :key="branch"
- :name="branch"
- :is-checked="isSelected(branch)"
- is-check-item
- data-testid="dropdown-item"
- @click="selectBranch(branch)"
- >
- {{ branch }}
- </gl-dropdown-item>
- <gl-dropdown-text v-show="isFetching" data-testid="dropdown-text-loading-icon">
- <gl-loading-icon size="sm" class="gl-mx-auto" />
- </gl-dropdown-text>
- <gl-dropdown-text
- v-if="!filteredResults.length && !isFetching"
- data-testid="empty-result-message"
- >
- <span class="gl-text-gray-500">{{ $options.i18n.noResultsMessage }}</span>
- </gl-dropdown-text>
- </gl-dropdown>
+ <gl-collapsible-listbox
+ :header-text="$options.i18n.branchHeaderTitle"
+ :toggle-text="value"
+ :items="listboxItems"
+ searchable
+ :search-placeholder="$options.i18n.branchSearchPlaceholder"
+ :searching="isFetching"
+ :selected="value"
+ :no-results-text="$options.i18n.noResultsMessage"
+ @search="searchTermChanged"
+ @select="selectBranch"
+ />
</template>
diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue
index 1febe8ceaab..b31ba4a100c 100644
--- a/app/assets/javascripts/projects/commit/components/form_modal.vue
+++ b/app/assets/javascripts/projects/commit/components/form_modal.vue
@@ -141,11 +141,7 @@ export default {
:value="targetProjectId"
/>
- <projects-dropdown
- class="gl-w-half"
- :value="targetProjectName"
- @selectProject="setSelectedProject"
- />
+ <projects-dropdown :value="targetProjectName" @selectProject="setSelectedProject" />
</gl-form-group>
<gl-form-group
@@ -155,12 +151,7 @@ export default {
>
<input id="start_branch" type="hidden" name="start_branch" :value="branch" />
- <branches-dropdown
- class="gl-w-half"
- :value="branch"
- :blanked="isRevert"
- @selectBranch="setBranch"
- />
+ <branches-dropdown :value="branch" :blanked="isRevert" @selectBranch="setBranch" />
</gl-form-group>
<gl-form-checkbox
diff --git a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
index 6288bcdaad0..d43f5b99e2c 100644
--- a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlSearchBoxByType, GlDropdownItem, GlDropdownText } from '@gitlab/ui';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import {
I18N_NO_RESULTS_MESSAGE,
@@ -10,10 +10,7 @@ import {
export default {
name: 'ProjectsDropdown',
components: {
- GlDropdown,
- GlSearchBoxByType,
- GlDropdownItem,
- GlDropdownText,
+ GlCollapsibleListbox,
},
props: {
value: {
@@ -41,17 +38,20 @@ export default {
project.name.toLowerCase().includes(lowerCasedFilterTerm),
);
},
+ listboxItems() {
+ return this.filteredResults.map(({ id, name }) => ({ value: id, text: name }));
+ },
selectedProject() {
return this.sortedProjects.find((project) => project.id === this.targetProjectId) || {};
},
},
methods: {
- selectProject(project) {
- this.$emit('selectProject', project.id);
- this.filterTerm = project.name; // when we select a project, we want the dropdown to filter to the selected project
- },
- isSelected(selectedProject) {
- return selectedProject === this.selectedProject;
+ selectProject(value) {
+ this.$emit('selectProject', value);
+
+ // when we select a project, we want the dropdown to filter to the selected project
+ const project = this.listboxItems.find((x) => x.value === value);
+ this.filterTerm = project?.text || '';
},
filterTermChanged(value) {
this.filterTerm = value;
@@ -60,28 +60,15 @@ export default {
};
</script>
<template>
- <gl-dropdown :text="selectedProject.name" :header-text="$options.i18n.projectHeaderTitle">
- <gl-search-box-by-type
- :value="filterTerm"
- trim
- autocomplete="off"
- :placeholder="$options.i18n.projectSearchPlaceholder"
- data-testid="dropdown-search-box"
- @input="filterTermChanged"
- />
- <gl-dropdown-item
- v-for="project in filteredResults"
- :key="project.name"
- :name="project.name"
- :is-checked="isSelected(project)"
- is-check-item
- data-testid="dropdown-item"
- @click="selectProject(project)"
- >
- {{ project.name }}
- </gl-dropdown-item>
- <gl-dropdown-text v-if="!filteredResults.length" data-testid="empty-result-message">
- <span class="gl-text-gray-500">{{ $options.i18n.noResultsMessage }}</span>
- </gl-dropdown-text>
- </gl-dropdown>
+ <gl-collapsible-listbox
+ :header-text="$options.i18n.projectHeaderTitle"
+ :items="listboxItems"
+ searchable
+ :search-placeholder="$options.i18n.projectSearchPlaceholder"
+ :selected="selectedProject.id"
+ :toggle-text="selectedProject.name"
+ :no-results-text="$options.i18n.noResultsMessage"
+ @search="filterTermChanged"
+ @select="selectProject"
+ />
</template>
diff --git a/app/assets/javascripts/projects/commits/index.js b/app/assets/javascripts/projects/commits/index.js
index 53169f689c9..f56884f605f 100644
--- a/app/assets/javascripts/projects/commits/index.js
+++ b/app/assets/javascripts/projects/commits/index.js
@@ -33,20 +33,31 @@ export const initCommitsRefSwitcher = () => {
if (!el) return false;
- const { projectId, ref, commitsPath } = el.dataset;
+ const { projectId, ref, commitsPath, refType } = el.dataset;
const commitsPathPrefix = commitsPath.match(COMMITS_PATH_REGEX)?.[0];
-
+ const useSymbolicRefNames = Boolean(refType);
return new Vue({
el,
render(createElement) {
return createElement(RefSelector, {
props: {
projectId,
- value: ref,
+ value: useSymbolicRefNames ? `refs/${refType}/${ref}` : ref,
+ useSymbolicRefNames,
+ refType,
},
on: {
input(selected) {
- visitUrl(`${commitsPathPrefix}/${selected}`);
+ if (useSymbolicRefNames) {
+ const matches = selected.match(/refs\/(heads|tags)\/(.+)/);
+ if (matches) {
+ visitUrl(`${commitsPathPrefix}/${matches[2]}?ref_type=${matches[1]}`);
+ } else {
+ visitUrl(`${commitsPathPrefix}/${selected}`);
+ }
+ } else {
+ visitUrl(`${commitsPathPrefix}/${selected}`);
+ }
},
},
});
diff --git a/app/assets/javascripts/projects/merge_requests/components/report_abuse_dropdown_item.vue b/app/assets/javascripts/projects/merge_requests/components/report_abuse_dropdown_item.vue
new file mode 100644
index 00000000000..31890249f41
--- /dev/null
+++ b/app/assets/javascripts/projects/merge_requests/components/report_abuse_dropdown_item.vue
@@ -0,0 +1,41 @@
+<script>
+import { GlDropdownItem } from '@gitlab/ui';
+import { MountingPortal } from 'portal-vue';
+import { s__ } from '~/locale';
+
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
+
+export default {
+ name: 'ReportAbuseDropdownItem',
+ components: {
+ GlDropdownItem,
+ MountingPortal,
+ AbuseCategorySelector,
+ },
+ i18n: {
+ reportAbuse: s__('ReportAbuse|Report abuse to administrator'),
+ },
+ data() {
+ return {
+ open: false,
+ };
+ },
+ methods: {
+ openDrawer() {
+ this.open = true;
+ },
+ closeDrawer() {
+ this.open = false;
+ },
+ },
+};
+</script>
+<template>
+ <span>
+ <gl-dropdown-item @click="openDrawer">{{ $options.i18n.reportAbuse }}</gl-dropdown-item>
+
+ <mounting-portal mount-to="#js-report-abuse-drawer" name="abuse-category-selector" append>
+ <abuse-category-selector :show-drawer="open" @close-drawer="closeDrawer" />
+ </mounting-portal>
+ </span>
+</template>
diff --git a/app/assets/javascripts/projects/merge_requests/index.js b/app/assets/javascripts/projects/merge_requests/index.js
new file mode 100644
index 00000000000..25a70121d68
--- /dev/null
+++ b/app/assets/javascripts/projects/merge_requests/index.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import ReportAbuseDropdownItem from './components/report_abuse_dropdown_item.vue';
+
+export const initReportAbuse = () => {
+ const el = document.getElementById('js-report-abuse-dropdown-item');
+
+ if (!el) return false;
+
+ const { reportAbusePath, reportedUserId, reportedFromUrl } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: { reportAbusePath, reportedUserId, reportedFromUrl },
+ render(createElement) {
+ return createElement(ReportAbuseDropdownItem);
+ },
+ });
+};
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 626ed67c466..6260c8dd4d0 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
@@ -56,6 +56,7 @@ export default {
},
update({ project: { branchRules } }) {
const branchRule = branchRules.nodes.find((rule) => rule.name === this.branch);
+ this.branchRule = branchRule;
this.branchProtection = branchRule?.branchProtection;
this.approvalRules = branchRule?.approvalRules;
this.statusChecks = branchRule?.externalStatusChecks?.nodes || [];
@@ -69,6 +70,7 @@ export default {
branchProtection: {},
approvalRules: {},
statusChecks: [],
+ branchRule: {},
matchingBranchesCount: null,
};
},
@@ -88,12 +90,12 @@ export default {
},
allowedToMergeHeader() {
return sprintf(this.$options.i18n.allowedToMergeHeader, {
- total: this.mergeAccessLevels.total,
+ total: this.mergeAccessLevels?.total || 0,
});
},
allowedToPushHeader() {
return sprintf(this.$options.i18n.allowedToPushHeader, {
- total: this.pushAccessLevels.total,
+ total: this.pushAccessLevels?.total || 0,
});
},
approvalsHeader() {
@@ -141,7 +143,7 @@ export default {
<template>
<gl-loading-icon v-if="$apollo.loading" />
- <div v-else-if="!branchProtection">{{ $options.i18n.noData }}</div>
+ <div v-else-if="!branchRule">{{ $options.i18n.noData }}</div>
<div v-else>
<strong data-testid="branch-title">{{ branchTitle }}</strong>
<p v-if="!allBranches" class="gl-mb-3 gl-text-gray-400">
diff --git a/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js b/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js
index 5ca864a412b..54120b3525d 100644
--- a/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js
+++ b/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js
@@ -4,6 +4,10 @@ import SharedRunnersToggle from '~/projects/settings/components/shared_runners_t
export default (containerId = 'toggle-shared-runners-form') => {
const containerEl = document.getElementById(containerId);
+ if (!containerEl) {
+ return null;
+ }
+
const {
isDisabledAndUnoverridable,
isEnabled,
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 41947834bdb..4a24df4b0dc 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
@@ -5,6 +5,7 @@ import { getAccessLevels } from '../../../utils';
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'),
@@ -62,6 +63,9 @@ export default {
isWildcard() {
return this.name.includes('*');
},
+ isProtected() {
+ return Boolean(this.branchProtection);
+ },
hasApprovalDetails() {
return this.approvalDetails.length;
},
@@ -105,10 +109,10 @@ export default {
if (this.isWildcard) {
approvalDetails.push(this.matchingBranchesText);
}
- if (this.branchProtection.allowForcePush) {
+ if (this.branchProtection?.allowForcePush) {
approvalDetails.push(this.$options.i18n.allowForcePush);
}
- if (this.branchProtection.codeOwnerApprovalRequired) {
+ if (this.branchProtection?.codeOwnerApprovalRequired) {
approvalDetails.push(this.$options.i18n.codeOwnerApprovalRequired);
}
if (this.statusChecksTotal) {
@@ -154,6 +158,10 @@ 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>
diff --git a/app/assets/javascripts/read_more.js b/app/assets/javascripts/read_more.js
index 769782607b8..692f375bb94 100644
--- a/app/assets/javascripts/read_more.js
+++ b/app/assets/javascripts/read_more.js
@@ -31,9 +31,9 @@ export default function initReadMore(triggerSelector = '.js-read-more-trigger')
triggerEl.addEventListener(
'click',
- (e) => {
+ () => {
targetEl.classList.add('is-expanded');
- e.target.remove();
+ triggerEl.remove();
},
{ once: true },
);
diff --git a/app/assets/javascripts/ref/components/ref_results_section.vue b/app/assets/javascripts/ref/components/ref_results_section.vue
index 4fa2a92ff03..52d1ed96b21 100644
--- a/app/assets/javascripts/ref/components/ref_results_section.vue
+++ b/app/assets/javascripts/ref/components/ref_results_section.vue
@@ -74,6 +74,11 @@ export default {
required: false,
default: '',
},
+ shouldShowCheck: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
totalCountText() {
@@ -82,6 +87,9 @@ export default {
},
methods: {
showCheck(item) {
+ if (!this.shouldShowCheck) {
+ return false;
+ }
return item.name === this.selectedRef || item.value === this.selectedRef;
},
},
diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue
index b75958e2ced..10967fb84ed 100644
--- a/app/assets/javascripts/ref/components/ref_selector.vue
+++ b/app/assets/javascripts/ref/components/ref_selector.vue
@@ -15,6 +15,8 @@ import {
REF_TYPE_BRANCHES,
REF_TYPE_TAGS,
REF_TYPE_COMMITS,
+ BRANCH_REF_TYPE,
+ TAG_REF_TYPE,
} from '../constants';
import createStore from '../stores';
import RefResultsSection from './ref_results_section.vue';
@@ -50,6 +52,11 @@ export default {
required: false,
default: '',
},
+ refType: {
+ type: String,
+ required: false,
+ default: null,
+ },
projectId: {
type: String,
required: true,
@@ -146,6 +153,12 @@ export default {
buttonText() {
return this.selectedRefForDisplay || this.i18n.noRefSelected;
},
+ isTagRefType() {
+ return this.refType === TAG_REF_TYPE;
+ },
+ isBranchRefType() {
+ return this.refType === BRANCH_REF_TYPE;
+ },
},
watch: {
// Keep the Vuex store synchronized if the parent
@@ -273,6 +286,7 @@ export default {
:show-header="showSectionHeaders"
data-testid="branches-section"
data-qa-selector="branches_section"
+ :should-show-check="!useSymbolicRefNames || isBranchRefType"
@selected="selectRef($event)"
/>
@@ -289,6 +303,7 @@ export default {
:error-message="i18n.tagsErrorMessage"
:show-header="showSectionHeaders"
data-testid="tags-section"
+ :should-show-check="!useSymbolicRefNames || isTagRefType"
@selected="selectRef($event)"
/>
diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js
index 397e3ed2ac8..f4faa535166 100644
--- a/app/assets/javascripts/ref/constants.js
+++ b/app/assets/javascripts/ref/constants.js
@@ -5,6 +5,8 @@ export const REF_TYPE_BRANCHES = 'REF_TYPE_BRANCHES';
export const REF_TYPE_TAGS = 'REF_TYPE_TAGS';
export const REF_TYPE_COMMITS = 'REF_TYPE_COMMITS';
export const ALL_REF_TYPES = Object.freeze([REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS]);
+export const BRANCH_REF_TYPE = 'heads';
+export const TAG_REF_TYPE = 'tags';
export const X_TOTAL_HEADER = 'x-total';
diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js
index e078d3333d4..9846ac0adb7 100644
--- a/app/assets/javascripts/ref/stores/mutations.js
+++ b/app/assets/javascripts/ref/stores/mutations.js
@@ -1,5 +1,5 @@
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import httpStatusCodes from '~/lib/utils/http_status';
+import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status';
import { X_TOTAL_HEADER } from '../constants';
import * as types from './mutation_types';
@@ -86,7 +86,7 @@ export default {
// 404's are expected when the search query doesn't match any commits
// and shouldn't be treated as an actual error
- error: error.response?.status !== httpStatusCodes.NOT_FOUND ? error : null,
+ error: error.response?.status !== HTTP_STATUS_NOT_FOUND ? error : null,
};
},
[types.RESET_COMMIT_MATCHES](state) {
diff --git a/app/assets/javascripts/ref_select_dropdown.js b/app/assets/javascripts/ref_select_dropdown.js
deleted file mode 100644
index c283fb1ea08..00000000000
--- a/app/assets/javascripts/ref_select_dropdown.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import $ from 'jquery';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-
-class RefSelectDropdown {
- constructor($dropdownButton, availableRefs) {
- const availableRefsValue =
- availableRefs || JSON.parse(document.getElementById('availableRefs').innerHTML);
- initDeprecatedJQueryDropdown($dropdownButton, {
- data: availableRefsValue,
- filterable: true,
- filterByText: true,
- remote: false,
- fieldName: $dropdownButton.data('fieldName'),
- filterInput: 'input[type="search"]',
- selectable: true,
- isSelectable(branch, $el) {
- return !$el.hasClass('is-active');
- },
- text(branch) {
- return branch;
- },
- id(branch) {
- return branch;
- },
- toggleLabel(branch) {
- return branch;
- },
- });
-
- const $dropdownContainer = $dropdownButton.closest('.dropdown');
- const $fieldInput = $(`input[name="${$dropdownButton.data('fieldName')}"]`, $dropdownContainer);
- const $filterInput = $('input[type="search"]', $dropdownContainer);
-
- $filterInput.on('keyup', (e) => {
- const keyCode = e.keyCode || e.which;
- if (keyCode !== 13) return;
-
- const ref = $filterInput.val().trim();
- if (ref === '') {
- return;
- }
-
- $fieldInput.val(ref);
- $('.dropdown-toggle-text', $dropdownButton).text(ref);
-
- $dropdownContainer.removeClass('open');
- });
- }
-}
-
-export default RefSelectDropdown;
diff --git a/app/assets/javascripts/repository/commits_service.js b/app/assets/javascripts/repository/commits_service.js
index f009c0310c5..d029f8cf89f 100644
--- a/app/assets/javascripts/repository/commits_service.js
+++ b/app/assets/javascripts/repository/commits_service.js
@@ -35,7 +35,7 @@ const fetchData = (projectPath, path, ref, offset) => {
gon.relative_url_root || '/',
projectPath,
'/-/refs/',
- ref,
+ encodeURIComponent(ref),
'/logs_tree/',
encodeURIComponent(removeLeadingSlash(path)),
);
diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js
index 3e6d2e675ed..a480710f8ac 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/index.js
+++ b/app/assets/javascripts/repository/components/blob_viewers/index.js
@@ -10,6 +10,8 @@ const viewers = {
audio: () => import('./audio_viewer.vue'),
svg: () => import('./image_viewer.vue'),
sketch: () => import('./sketch_viewer.vue'),
+ notebook: () => import('./notebook_viewer.vue'),
+ openapi: () => import('./openapi_viewer.vue'),
};
export const loadViewer = (type, isUsingLfs) => {
diff --git a/app/assets/javascripts/repository/components/blob_viewers/notebook_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/notebook_viewer.vue
new file mode 100644
index 00000000000..1114a0942ec
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/notebook_viewer.vue
@@ -0,0 +1,31 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import notebookLoader from '~/blob/notebook';
+import { stripPathTail } from '~/lib/utils/url_utility';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ },
+ props: {
+ blob: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ url: this.blob.rawPath,
+ };
+ },
+ mounted() {
+ notebookLoader({ el: this.$refs.viewer, relativeRawPath: stripPathTail(this.url) });
+ },
+};
+</script>
+
+<template>
+ <div ref="viewer" :data-endpoint="url" data-testid="notebook">
+ <gl-loading-icon class="gl-my-4" size="lg" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/openapi_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/openapi_viewer.vue
new file mode 100644
index 00000000000..5665e4b0ec4
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/openapi_viewer.vue
@@ -0,0 +1,24 @@
+<script>
+import renderOpenApi from '~/blob/openapi';
+
+export default {
+ props: {
+ blob: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ url: this.blob.rawPath,
+ };
+ },
+ mounted() {
+ renderOpenApi(this.$refs.viewer);
+ },
+};
+</script>
+
+<template>
+ <div ref="viewer" class="file-content" :data-endpoint="url" data-testid="openapi"></div>
+</template>
diff --git a/app/assets/javascripts/repository/components/fork_info.vue b/app/assets/javascripts/repository/components/fork_info.vue
new file mode 100644
index 00000000000..980fa140eb5
--- /dev/null
+++ b/app/assets/javascripts/repository/components/fork_info.vue
@@ -0,0 +1,146 @@
+<script>
+import { GlIcon, GlLink, GlSkeletonLoader } from '@gitlab/ui';
+import { s__, sprintf, n__ } from '~/locale';
+import { createAlert } from '~/flash';
+import forkDetailsQuery from '../queries/fork_details.query.graphql';
+
+export const i18n = {
+ forkedFrom: s__('ForkedFromProjectPath|Forked from'),
+ inaccessibleProject: s__('ForkedFromProjectPath|Forked from an inaccessible project.'),
+ upToDate: s__('ForksDivergence|Up to date with the upstream repository.'),
+ unknown: s__('ForksDivergence|This fork has diverged from the upstream repository.'),
+ behind: s__('ForksDivergence|%{behind} %{commit_word} behind'),
+ ahead: s__('ForksDivergence|%{ahead} %{commit_word} ahead of'),
+ behindAndAhead: s__('ForksDivergence|%{messages} the upstream repository.'),
+ error: s__('ForksDivergence|Failed to fetch fork details. Try again later.'),
+};
+
+export default {
+ i18n,
+ components: {
+ GlIcon,
+ GlLink,
+ GlSkeletonLoader,
+ },
+ apollo: {
+ project: {
+ query: forkDetailsQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ ref: this.selectedRef,
+ };
+ },
+ skip() {
+ return !this.sourceName;
+ },
+ error(error) {
+ createAlert({
+ message: this.$options.i18n.error,
+ captureError: true,
+ error,
+ });
+ },
+ },
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ selectedRef: {
+ type: String,
+ required: true,
+ },
+ sourceName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ sourcePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ project: {
+ forkDetails: {
+ ahead: null,
+ behind: null,
+ },
+ },
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.project.loading;
+ },
+ ahead() {
+ return this.project?.forkDetails?.ahead;
+ },
+ behind() {
+ return this.project?.forkDetails?.behind;
+ },
+ behindText() {
+ return sprintf(this.$options.i18n.behind, {
+ behind: this.behind,
+ commit_word: n__('commit', 'commits', this.behind),
+ });
+ },
+ aheadText() {
+ return sprintf(this.$options.i18n.ahead, {
+ ahead: this.ahead,
+ commit_word: n__('commit', 'commits', this.ahead),
+ });
+ },
+ isUnknownDivergence() {
+ return (!this.ahead && this.ahead !== 0) || (!this.behind && this.behind !== 0);
+ },
+ behindAheadMessage() {
+ const messages = [];
+ if (this.behind > 0) {
+ messages.push(this.behindText);
+ }
+ if (this.ahead > 0) {
+ messages.push(this.aheadText);
+ }
+ return messages.join(', ');
+ },
+ hasBehindAheadMessage() {
+ return this.behindAheadMessage.length > 0;
+ },
+ forkDivergenceMessage() {
+ if (this.isUnknownDivergence) {
+ return this.$options.i18n.unknown;
+ }
+ if (this.hasBehindAheadMessage) {
+ return sprintf(this.$options.i18n.behindAndAhead, {
+ messages: this.behindAheadMessage,
+ });
+ }
+ return this.$options.i18n.upToDate;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="info-well gl-sm-display-flex gl-flex-direction-column">
+ <div class="well-segment gl-p-5 gl-w-full gl-display-flex">
+ <gl-icon name="fork" :size="16" class="gl-display-block gl-m-4 gl-text-center" />
+ <div v-if="sourceName">
+ {{ $options.i18n.forkedFrom }}
+ <gl-link data-qa-selector="forked_from_link" :href="sourcePath">{{ sourceName }}</gl-link>
+ <gl-skeleton-loader v-if="isLoading" :lines="1" />
+ <div v-else class="gl-text-secondary">
+ {{ forkDivergenceMessage }}
+ </div>
+ </div>
+ <div v-else data-testid="inaccessible-project" class="gl-align-items-center gl-display-flex">
+ {{ $options.i18n.inaccessibleProject }}
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index 4a8f83458f4..f6d6004ba96 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -2,12 +2,13 @@
import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql';
import { createAlert } from '~/flash';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { __ } from '~/locale';
import {
TREE_PAGE_SIZE,
TREE_INITIAL_FETCH_COUNT,
TREE_PAGE_LIMIT,
COMMIT_BATCH_SIZE,
+ GITALY_UNAVAILABLE_CODE,
+ i18n,
} from '../constants';
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
@@ -17,6 +18,7 @@ import FilePreview from './preview/index.vue';
import FileTable from './table/index.vue';
export default {
+ i18n,
components: {
FileTable,
FilePreview,
@@ -142,10 +144,19 @@ export default {
}
})
.catch((error) => {
+ let gitalyUnavailableError;
+ if (error.graphQLErrors) {
+ gitalyUnavailableError = error.graphQLErrors.find(
+ (e) => e?.extensions?.code === GITALY_UNAVAILABLE_CODE,
+ );
+ }
+ const message = gitalyUnavailableError
+ ? this.$options.i18n.gitalyError
+ : this.$options.i18n.generalError;
createAlert({
- message: __('An error occurred while fetching folder content.'),
+ message,
+ captureError: true,
});
- throw error;
});
},
normalizeData(key, data) {
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index e194bddcc56..5098053c4f7 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -1,5 +1,6 @@
import { __ } from '~/locale';
+export const GITALY_UNAVAILABLE_CODE = 'unavailable';
export const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page
export const TREE_PAGE_SIZE = 100; // the amount of items to be fetched per (batch) request
export const TREE_INITIAL_FETCH_COUNT = TREE_PAGE_LIMIT / TREE_PAGE_SIZE; // the amount of (batch) requests to make
@@ -100,3 +101,8 @@ export const LEGACY_FILE_TYPES = [
'cargo_toml',
'go_mod',
];
+
+export const i18n = {
+ generalError: __('An error occurred while fetching folder content.'),
+ gitalyError: __('Error: Gitaly is unavailable. Contact your administrator.'),
+};
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index e9214e3acff..e5d22f50d72 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -11,6 +11,7 @@ import RefSelector from '~/ref/components/ref_selector.vue';
import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
+import ForkInfo from './components/fork_info.vue';
import LastCommit from './components/last_commit.vue';
import BlobControls from './components/blob_controls.vue';
import apolloProvider from './graphql';
@@ -63,6 +64,28 @@ export default function setupVueRepositoryList() {
},
});
+ const initForkInfo = () => {
+ const forkEl = document.getElementById('js-fork-info');
+ if (!forkEl) {
+ return null;
+ }
+ const { sourceName, sourcePath } = forkEl.dataset;
+ return new Vue({
+ el: forkEl,
+ apolloProvider,
+ render(h) {
+ return h(ForkInfo, {
+ props: {
+ projectPath,
+ selectedRef: ref,
+ sourceName,
+ sourcePath,
+ },
+ });
+ },
+ });
+ };
+
const initLastCommitApp = () =>
new Vue({
el: document.getElementById('js-last-commit'),
@@ -118,6 +141,7 @@ export default function setupVueRepositoryList() {
initLastCommitApp();
initBlobControlsApp();
+ initForkInfo();
initRefSwitcher();
router.afterEach(({ params: { path } }) => {
diff --git a/app/assets/javascripts/repository/queries/fork_details.query.graphql b/app/assets/javascripts/repository/queries/fork_details.query.graphql
new file mode 100644
index 00000000000..d1a37d00d55
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/fork_details.query.graphql
@@ -0,0 +1,9 @@
+query getForkDetails($projectPath: ID!, $ref: String) {
+ project(fullPath: $projectPath) {
+ id
+ forkDetails(ref: $ref) {
+ ahead
+ behind
+ }
+ }
+}
diff --git a/app/assets/javascripts/repository/utils/ref_switcher_utils.js b/app/assets/javascripts/repository/utils/ref_switcher_utils.js
index 8ff52104c93..f296b5e9b4a 100644
--- a/app/assets/javascripts/repository/utils/ref_switcher_utils.js
+++ b/app/assets/javascripts/repository/utils/ref_switcher_utils.js
@@ -17,6 +17,7 @@ const NAMESPACE_TARGET_REGEX = /(\/-\/(blob|tree))\/.*?\/(.*)/;
*/
export function generateRefDestinationPath(projectRootPath, selectedRef) {
const currentPath = window.location.pathname;
+ const encodedHash = '%23';
let namespace = '/-/tree';
let target;
const match = NAMESPACE_TARGET_REGEX.exec(currentPath);
@@ -24,7 +25,12 @@ export function generateRefDestinationPath(projectRootPath, selectedRef) {
[, namespace, , target] = match;
}
- const destinationPath = joinPaths(projectRootPath, namespace, selectedRef, target);
+ const destinationPath = joinPaths(
+ projectRootPath,
+ namespace,
+ encodeURI(selectedRef).replace(/#/g, encodedHash),
+ target,
+ );
return `${destinationPath}${window.location.hash}`;
}
diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js
index ba12f31ef87..d4ee857c9c1 100644
--- a/app/assets/javascripts/search/index.js
+++ b/app/assets/javascripts/search/index.js
@@ -1,6 +1,7 @@
import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result';
import { queryToObject } from '~/lib/utils/url_utility';
import refreshCounts from '~/pages/search/show/refresh_counts';
+import syntaxHighlight from '~/syntax_highlight';
import { initSidebar, sidebarInitState } from './sidebar';
import { initSearchSort } from './sort';
import createStore from './store';
@@ -8,10 +9,14 @@ import { initTopbar } from './topbar';
import { initBlobRefSwitcher } from './under_topbar';
export const initSearchApp = () => {
- const query = queryToObject(window.location.search);
- const navigation = sidebarInitState();
+ syntaxHighlight(document.querySelectorAll('.js-search-results'));
+ const query = queryToObject(window.location.search, { gatherArrays: true });
+ const { navigationJsonParsed: navigation } = sidebarInitState() || {};
- const store = createStore({ query, navigation });
+ const store = createStore({
+ query,
+ navigation,
+ });
initTopbar(store);
initSidebar(store);
diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
index 4ddf695f61a..fbfc24a94ae 100644
--- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
@@ -21,6 +21,6 @@ export default {
<template>
<div>
<radio-filter :class="ffBasedXPadding" :filter-data="$options.confidentialFilterData" />
- <hr class="gl-my-5 gl-border-gray-100" />
+ <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" />
</div>
</template>
diff --git a/app/assets/javascripts/search/sidebar/components/results_filters.vue b/app/assets/javascripts/search/sidebar/components/results_filters.vue
index 9b993ab9a86..ff7a044736d 100644
--- a/app/assets/javascripts/search/sidebar/components/results_filters.vue
+++ b/app/assets/javascripts/search/sidebar/components/results_filters.vue
@@ -44,7 +44,7 @@ export default {
<form class="gl-pt-5 gl-md-pt-0" @submit.prevent="applyQuery">
<hr
v-if="searchPageVerticalNavFeatureFlag"
- class="gl-my-5 gl-border-gray-100 gl-display-none gl-md-display-block"
+ class="gl-my-5 gl-mx-5 gl-border-gray-100 gl-display-none gl-md-display-block"
/>
<status-filter v-if="showStatusFilter" />
<confidentiality-filter v-if="showConfidentialityFilter" />
diff --git a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
index 7a03306e2f9..3c280a5d696 100644
--- a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
+++ b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
@@ -1,13 +1,10 @@
<script>
import { GlNav, GlNavItem, GlIcon } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
-import { formatNumber, s__ } from '~/locale';
+import { s__ } from '~/locale';
import Tracking from '~/tracking';
-import {
- NAV_LINK_DEFAULT_CLASSES,
- NUMBER_FORMATING_OPTIONS,
- NAV_LINK_COUNT_DEFAULT_CLASSES,
-} from '../constants';
+import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants';
+import { formatSearchResultCount } from '../../store/utils';
export default {
name: 'ScopeNavigation',
@@ -29,11 +26,7 @@ export default {
methods: {
...mapActions(['fetchSidebarCount']),
showFormatedCount(count) {
- if (!count) {
- return '0';
- }
- const countNumber = parseInt(count.replace(/,/g, ''), 10);
- return formatNumber(countNumber, NUMBER_FORMATING_OPTIONS);
+ return formatSearchResultCount(count);
},
isCountOverLimit(count) {
return count.includes('+');
@@ -82,6 +75,6 @@ export default {
</span>
</gl-nav-item>
</gl-nav>
- <hr class="gl-mt-5 gl-mb-0 gl-border-gray-100 gl-md-display-none" />
+ <hr class="gl-mt-5 gl-mx-5 gl-mb-0 gl-border-gray-100 gl-md-display-none" />
</nav>
</template>
diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue
index eaf7d95822a..4da96a41ef7 100644
--- a/app/assets/javascripts/search/sidebar/components/status_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue
@@ -21,6 +21,6 @@ export default {
<template>
<div>
<radio-filter :class="ffBasedXPadding" :filter-data="$options.stateFilterData" />
- <hr class="gl-my-5 gl-border-gray-100" />
+ <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" />
</div>
</template>
diff --git a/app/assets/javascripts/search/sidebar/constants/index.js b/app/assets/javascripts/search/sidebar/constants/index.js
index a9c031f91a4..19b1ad0905b 100644
--- a/app/assets/javascripts/search/sidebar/constants/index.js
+++ b/app/assets/javascripts/search/sidebar/constants/index.js
@@ -1,13 +1,16 @@
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 = [
+export const SCOPE_BLOB = 'blobs';
+export const LABEL_DEFAULT_CLASSES = [
'gl-display-flex',
'gl-flex-direction-row',
'gl-flex-wrap-nowrap',
- 'gl-justify-content-space-between',
'gl-text-gray-900',
];
-
+export const NAV_LINK_DEFAULT_CLASSES = [
+ ...LABEL_DEFAULT_CLASSES,
+ 'gl-justify-content-space-between',
+];
export const NAV_LINK_COUNT_DEFAULT_CLASSES = ['gl-font-sm', 'gl-font-weight-normal'];
+export const HR_DEFAULT_CLASSES = ['gl-my-5', 'gl-mx-5', 'gl-border-gray-100'];
+export const ONLY_SHOW_MD = ['gl-display-none', 'gl-md-display-block'];
diff --git a/app/assets/javascripts/search/sidebar/index.js b/app/assets/javascripts/search/sidebar/index.js
index c6b1257c4ef..415f6f7454c 100644
--- a/app/assets/javascripts/search/sidebar/index.js
+++ b/app/assets/javascripts/search/sidebar/index.js
@@ -6,11 +6,11 @@ Vue.use(Translate);
export const sidebarInitState = () => {
const el = document.getElementById('js-search-sidebar');
-
if (!el) return {};
- const { navigation } = el.dataset;
- return JSON.parse(navigation);
+ const { navigationJson } = el.dataset;
+ const navigationJsonParsed = JSON.parse(navigationJson);
+ return { navigationJsonParsed };
};
export const initSidebar = (store) => {
diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js
index 678bd82c7a6..e4f67f624ca 100644
--- a/app/assets/javascripts/search/store/constants.js
+++ b/app/assets/javascripts/search/store/constants.js
@@ -10,3 +10,5 @@ export const GROUPS_LOCAL_STORAGE_KEY = 'global-search-frequent-groups';
export const PROJECTS_LOCAL_STORAGE_KEY = 'global-search-frequent-projects';
export const SIDEBAR_PARAMS = [stateFilterData.filterParam, confidentialFilterData.filterParam];
+
+export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' };
diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js
index f8198017bf8..acb99c60426 100644
--- a/app/assets/javascripts/search/store/utils.js
+++ b/app/assets/javascripts/search/store/utils.js
@@ -1,5 +1,12 @@
import AccessorUtilities from '~/lib/utils/accessor';
-import { MAX_FREQUENT_ITEMS, MAX_FREQUENCY, SIDEBAR_PARAMS } from './constants';
+import { formatNumber } from '~/locale';
+import { joinPaths } from '~/lib/utils/url_utility';
+import {
+ MAX_FREQUENT_ITEMS,
+ MAX_FREQUENCY,
+ SIDEBAR_PARAMS,
+ NUMBER_FORMATING_OPTIONS,
+} from './constants';
function extractKeys(object, keyList) {
return Object.fromEntries(keyList.map((key) => [key, object[key]]));
@@ -90,3 +97,18 @@ export const isSidebarDirty = (currentQuery, urlQuery) => {
return userAddedParam || userChangedExistingParam;
});
};
+
+export const formatSearchResultCount = (count) => {
+ if (!count) {
+ return '0';
+ }
+
+ const countNumber = typeof count === 'string' ? parseInt(count.replace(/,/g, ''), 10) : count;
+ return formatNumber(countNumber, NUMBER_FORMATING_OPTIONS);
+};
+
+export const getAggregationsUrl = () => {
+ const currentUrl = new URL(window.location.href);
+ currentUrl.pathname = joinPaths('/search', 'aggregations');
+ return currentUrl.toString();
+};
diff --git a/app/assets/javascripts/search/topbar/constants.js b/app/assets/javascripts/search/topbar/constants.js
index 121c15199dd..5b1c5819f2b 100644
--- a/app/assets/javascripts/search/topbar/constants.js
+++ b/app/assets/javascripts/search/topbar/constants.js
@@ -20,4 +20,4 @@ export const PROJECT_DATA = {
fullName: 'name_with_namespace',
};
-export const SYNTAX_OPTIONS_DOCUMENT = 'drawers/user/search/advanced_search.md';
+export const SYNTAX_OPTIONS_DOCUMENT = 'drawers/drawers/advanced_search_syntax.md';
diff --git a/app/assets/javascripts/self_monitor/store/actions.js b/app/assets/javascripts/self_monitor/store/actions.js
index 7198dbe8b04..d94fd77dd42 100644
--- a/app/assets/javascripts/self_monitor/store/actions.js
+++ b/app/assets/javascripts/self_monitor/store/actions.js
@@ -1,6 +1,6 @@
import axios from '~/lib/utils/axios_utils';
import { backOff } from '~/lib/utils/common_utils';
-import statusCodes, { HTTP_STATUS_ACCEPTED } from '~/lib/utils/http_status';
+import { HTTP_STATUS_ACCEPTED, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { __, s__ } from '~/locale';
import * as types from './mutation_types';
@@ -43,7 +43,7 @@ export const requestCreateProject = ({ dispatch, state, commit }) => {
export const requestCreateProjectStatus = ({ dispatch, state }, jobId) => {
backOffRequest(() => axios.get(state.createProjectStatusEndpoint, { params: { job_id: jobId } }))
.then((resp) => {
- if (resp.status === statusCodes.OK) {
+ if (resp.status === HTTP_STATUS_OK) {
dispatch('requestCreateProjectSuccess', resp.data);
}
})
@@ -95,7 +95,7 @@ export const requestDeleteProject = ({ dispatch, state, commit }) => {
export const requestDeleteProjectStatus = ({ dispatch, state }, jobId) => {
backOffRequest(() => axios.get(state.deleteProjectStatusEndpoint, { params: { job_id: jobId } }))
.then((resp) => {
- if (resp.status === statusCodes.OK) {
+ if (resp.status === HTTP_STATUS_OK) {
dispatch('requestDeleteProjectSuccess', resp.data);
}
})
diff --git a/app/assets/javascripts/set_status_modal/set_status_form.vue b/app/assets/javascripts/set_status_modal/set_status_form.vue
index dd27a12cbee..c96189c7cae 100644
--- a/app/assets/javascripts/set_status_modal/set_status_form.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_form.vue
@@ -8,7 +8,6 @@ import {
GlFormInputGroup,
GlDropdown,
GlDropdownItem,
- GlSprintf,
GlFormGroup,
} from '@gitlab/ui';
import $ from 'jquery';
@@ -16,7 +15,8 @@ import SafeHtml from '~/vue_shared/directives/safe_html';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import * as Emoji from '~/emoji';
import { s__ } from '~/locale';
-import { TIME_RANGES_WITH_NEVER, AVAILABILITY_STATUS } from './constants';
+import { formatDate, newDate, nSecondsAfter, isToday } from '~/lib/utils/datetime_utility';
+import { TIME_RANGES_WITH_NEVER, AVAILABILITY_STATUS, NEVER_TIME_RANGE } from './constants';
export default {
components: {
@@ -27,7 +27,6 @@ export default {
GlFormInputGroup,
GlDropdown,
GlDropdownItem,
- GlSprintf,
GlFormGroup,
EmojiPicker: () => import('~/emoji/components/picker.vue'),
},
@@ -56,7 +55,7 @@ export default {
clearStatusAfter: {
type: Object,
required: false,
- default: () => ({}),
+ default: null,
},
currentClearStatusAfter: {
type: String,
@@ -80,6 +79,21 @@ export default {
noEmoji() {
return this.emojiTag === '';
},
+ clearStatusAfterDropdownText() {
+ if (this.clearStatusAfter === null && this.currentClearStatusAfter.length) {
+ return this.formatClearStatusAfterDate(new Date(this.currentClearStatusAfter));
+ }
+
+ if (this.clearStatusAfter?.duration?.seconds) {
+ const clearStatusAfterDate = nSecondsAfter(
+ newDate(),
+ this.clearStatusAfter.duration.seconds,
+ );
+ return this.formatClearStatusAfterDate(clearStatusAfterDate);
+ }
+
+ return NEVER_TIME_RANGE.label;
+ },
},
mounted() {
this.setupEmojiListAndAutocomplete();
@@ -124,6 +138,13 @@ export default {
this.$emit('message-input', '');
this.clearEmoji();
},
+ formatClearStatusAfterDate(date) {
+ if (isToday(date)) {
+ return formatDate(date, 'h:MMtt');
+ }
+
+ return formatDate(date, 'mmm d, yyyy h:MMtt');
+ },
},
TIME_RANGES_WITH_NEVER,
AVAILABILITY_STATUS,
@@ -202,7 +223,7 @@ export default {
<gl-form-group :label="$options.i18n.clearStatusAfterDropdownLabel" class="gl-mb-0">
<gl-dropdown
block
- :text="clearStatusAfter.label"
+ :text="clearStatusAfterDropdownText"
data-testid="clear-status-at-dropdown"
toggle-class="gl-mb-0 gl-form-input-md"
>
@@ -214,14 +235,6 @@ export default {
>{{ after.label }}</gl-dropdown-item
>
</gl-dropdown>
-
- <template v-if="currentClearStatusAfter.length" #description>
- <span data-testid="clear-status-at-message">
- <gl-sprintf :message="$options.i18n.clearStatusAfterMessage">
- <template #date>{{ currentClearStatusAfter }}</template>
- </gl-sprintf>
- </span>
- </template>
</gl-form-group>
</div>
</template>
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index 5becc03646e..e7d028e8d23 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -6,8 +6,8 @@ import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { s__ } from '~/locale';
import { updateUserStatus } from '~/rest_api';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { isUserBusy } from './utils';
-import { NEVER_TIME_RANGE, AVAILABILITY_STATUS } from './constants';
+import { isUserBusy, computedClearStatusAfterValue } from './utils';
+import { AVAILABILITY_STATUS } from './constants';
import SetStatusForm from './set_status_form.vue';
Vue.use(GlToast);
@@ -53,9 +53,17 @@ export default {
message: this.currentMessage,
modalId: 'set-user-status-modal',
availability: isUserBusy(this.currentAvailability),
- clearStatusAfter: NEVER_TIME_RANGE,
+ clearStatusAfter: null,
};
},
+ computed: {
+ shouldIncludeClearStatusAfterInApiRequest() {
+ return this.clearStatusAfter !== null;
+ },
+ clearStatusAfterApiRequestValue() {
+ return computedClearStatusAfterValue(this.clearStatusAfter);
+ },
+ },
mounted() {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
@@ -70,14 +78,21 @@ export default {
this.setStatus();
},
setStatus() {
- const { emoji, message, availability, clearStatusAfter } = this;
+ const {
+ emoji,
+ message,
+ availability,
+ shouldIncludeClearStatusAfterInApiRequest,
+ clearStatusAfterApiRequestValue,
+ } = this;
updateUserStatus({
emoji,
message,
availability: availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET,
- clearStatusAfter:
- clearStatusAfter.label === NEVER_TIME_RANGE.label ? null : clearStatusAfter.shortcut,
+ ...(shouldIncludeClearStatusAfterInApiRequest
+ ? { clearStatusAfter: clearStatusAfterApiRequestValue }
+ : {}),
})
.then(this.onUpdateSuccess)
.catch(this.onUpdateFail);
diff --git a/app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue b/app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue
index c709611e13d..48693485116 100644
--- a/app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue
@@ -1,9 +1,7 @@
<script>
-import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
-import dateFormat from '~/lib/dateformat';
import SetStatusForm from './set_status_form.vue';
-import { isUserBusy } from './utils';
-import { NEVER_TIME_RANGE, AVAILABILITY_STATUS } from './constants';
+import { isUserBusy, computedClearStatusAfterValue } from './utils';
+import { AVAILABILITY_STATUS } from './constants';
export default {
components: { SetStatusForm },
@@ -13,15 +11,16 @@ export default {
emoji: this.fields.emoji.value,
message: this.fields.message.value,
availability: isUserBusy(this.fields.availability.value),
- clearStatusAfter: NEVER_TIME_RANGE,
+ clearStatusAfter: null,
currentClearStatusAfter: this.fields.clearStatusAfter.value,
};
},
computed: {
- clearStatusAfterInputValue() {
- return this.clearStatusAfter.label === NEVER_TIME_RANGE.label
- ? null
- : this.clearStatusAfter.shortcut;
+ showClearStatusAfterHiddenInput() {
+ return this.clearStatusAfter !== null;
+ },
+ clearStatusAfterHiddenInputValue() {
+ return computedClearStatusAfterValue(this.clearStatusAfter);
},
availabilityInputValue() {
return this.availability
@@ -29,18 +28,6 @@ export default {
: this.$options.AVAILABILITY_STATUS.NOT_SET;
},
},
- mounted() {
- this.$options.formEl = document.querySelector('form.js-edit-user');
-
- if (!this.$options.formEl) return;
-
- this.$options.formEl.addEventListener('ajax:success', this.handleFormSuccess);
- },
- beforeDestroy() {
- if (!this.$options.formEl) return;
-
- this.$options.formEl.removeEventListener('ajax:success', this.handleFormSuccess);
- },
methods: {
handleMessageInput(value) {
this.message = value;
@@ -54,24 +41,6 @@ export default {
handleAvailabilityInput(value) {
this.availability = value;
},
- handleFormSuccess() {
- if (!this.clearStatusAfter?.duration?.seconds) {
- this.currentClearStatusAfter = '';
-
- return;
- }
-
- const now = new Date();
- const currentClearStatusAfterDate = new Date(
- now.getTime() + secondsToMilliseconds(this.clearStatusAfter.duration.seconds),
- );
-
- this.currentClearStatusAfter = dateFormat(
- currentClearStatusAfterDate,
- "UTC:yyyy-mm-dd HH:MM:ss 'UTC'",
- );
- this.clearStatusAfter = NEVER_TIME_RANGE;
- },
},
AVAILABILITY_STATUS,
formEl: null,
@@ -83,7 +52,12 @@ export default {
<input :value="emoji" type="hidden" :name="fields.emoji.name" />
<input :value="message" type="hidden" :name="fields.message.name" />
<input :value="availabilityInputValue" type="hidden" :name="fields.availability.name" />
- <input :value="clearStatusAfterInputValue" type="hidden" :name="fields.clearStatusAfter.name" />
+ <input
+ v-if="showClearStatusAfterHiddenInput"
+ :value="clearStatusAfterHiddenInputValue"
+ type="hidden"
+ :name="fields.clearStatusAfter.name"
+ />
<set-status-form
default-emoji="speech_balloon"
:emoji="emoji"
diff --git a/app/assets/javascripts/set_status_modal/utils.js b/app/assets/javascripts/set_status_modal/utils.js
index 950091195d2..11e47fdf956 100644
--- a/app/assets/javascripts/set_status_modal/utils.js
+++ b/app/assets/javascripts/set_status_modal/utils.js
@@ -1,4 +1,12 @@
-import { AVAILABILITY_STATUS } from './constants';
+import { AVAILABILITY_STATUS, NEVER_TIME_RANGE } from './constants';
export const isUserBusy = (status = '') =>
Boolean(status.length && status.toLowerCase().trim() === AVAILABILITY_STATUS.BUSY);
+
+export const computedClearStatusAfterValue = (value) => {
+ if (value === null || value.name === NEVER_TIME_RANGE.name) {
+ return null;
+ }
+
+ return value.shortcut;
+};
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue
index 2a78db352d7..04c62c99a11 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue
@@ -1,5 +1,4 @@
<script>
-import $ from 'jquery';
import Vue from 'vue';
import Vuex, { mapState, mapActions, mapGetters } from 'vuex';
import { isInViewport } from '~/lib/utils/common_utils';
@@ -260,8 +259,8 @@ export default {
target?.parentElement?.classList.contains(className),
);
- const hasExceptionParent = ['.js-btn-back', '.js-labels-list'].some(
- (className) => $(target).parents(className).length,
+ const hasExceptionParent = ['.js-btn-back', '.js-labels-list'].some((className) =>
+ target?.closest(className),
);
const isInDropdownButtonCollapsed = this.$refs.dropdownButtonCollapsed?.$el.contains(target);
diff --git a/app/assets/javascripts/super_sidebar/components/bottom_bar.vue b/app/assets/javascripts/super_sidebar/components/bottom_bar.vue
new file mode 100644
index 00000000000..fea29458f45
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/bottom_bar.vue
@@ -0,0 +1,24 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ i18n: {
+ help: __('Help'),
+ new: __('New'),
+ },
+};
+</script>
+
+<template>
+ <div class="bottom-links gl-p-3">
+ <a href="#" class="gl-text-black-normal"
+ ><gl-icon name="question-o" class="gl-mr-3 gl-text-gray-300 gl-text-black-normal!" />{{
+ $options.i18n.help
+ }}</a
+ >
+ </div>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher.vue b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
new file mode 100644
index 00000000000..f1ddb8290a0
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
@@ -0,0 +1,83 @@
+<script>
+import { GlAvatar, GlSearchBoxByType } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { contextSwitcherItems } from '../mock_data';
+import NavItem from './nav_item.vue';
+
+export default {
+ components: {
+ GlAvatar,
+ GlSearchBoxByType,
+ NavItem,
+ },
+ i18n: {
+ contextNavigation: s__('Navigation|Context navigation'),
+ switchTo: s__('Navigation|Switch to...'),
+ recentProjects: s__('Navigation|Recent projects'),
+ recentGroups: s__('Navigation|Recent groups'),
+ },
+ contextSwitcherItems,
+ viewAllProjectsItem: {
+ title: s__('Navigation|View all projects'),
+ link: '/projects',
+ icon: 'project',
+ },
+ viewAllGroupsItem: {
+ title: s__('Navigation|View all groups'),
+ link: '/groups',
+ icon: 'group',
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-search-box-by-type />
+ <nav :aria-label="$options.i18n.contextNavigation">
+ <ul class="gl-p-0 gl-list-style-none">
+ <li>
+ <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3">
+ {{ $options.i18n.switchTo }}
+ </div>
+ <ul :aria-label="$options.i18n.switchTo" class="gl-p-0">
+ <nav-item :item="$options.contextSwitcherItems.yourWork" />
+ </ul>
+ </li>
+ <li>
+ <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3">
+ {{ $options.i18n.recentProjects }}
+ </div>
+ <ul :aria-label="$options.i18n.recentProjects" class="gl-p-0">
+ <nav-item
+ v-for="project in $options.contextSwitcherItems.recentProjects"
+ :key="project.title"
+ :item="project"
+ >
+ <template #icon>
+ <gl-avatar shape="rect" :size="32" :src="project.avatar" />
+ </template>
+ </nav-item>
+ <nav-item :item="$options.viewAllProjectsItem" />
+ </ul>
+ </li>
+ <li>
+ <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3">
+ {{ $options.i18n.recentGroups }}
+ </div>
+ <ul :aria-label="$options.i18n.recentGroups" class="gl-p-0">
+ <nav-item
+ v-for="project in $options.contextSwitcherItems.recentGroups"
+ :key="project.title"
+ :item="project"
+ >
+ <template #icon>
+ <gl-avatar shape="rect" :size="32" :src="project.avatar" />
+ </template>
+ </nav-item>
+ <nav-item :item="$options.viewAllGroupsItem" />
+ </ul>
+ </li>
+ </ul>
+ </nav>
+ </div>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
new file mode 100644
index 00000000000..b6f058f7aee
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlTruncate, GlAvatar, GlCollapseToggleDirective, GlIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlTruncate,
+ GlAvatar,
+ GlIcon,
+ },
+ directives: {
+ CollapseToggle: GlCollapseToggleDirective,
+ },
+ props: {
+ context: {
+ type: Object,
+ required: true,
+ },
+ expanded: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ collapseIcon() {
+ return this.expanded ? 'chevron-up' : 'chevron-down';
+ },
+ },
+};
+</script>
+
+<template>
+ <button
+ v-collapse-toggle.context-switcher
+ type="button"
+ class="context-switcher-toggle gl-bg-transparent gl-border-0 border-top border-bottom gl-border-gray-a-08 gl-box-shadow-none gl-display-flex gl-align-items-center gl-font-weight-bold gl-w-full gl-pl-3 gl-pr-5 gl-h-8"
+ >
+ <gl-avatar :size="32" shape="rect" :src="context.avatar" class="gl-mr-3" />
+ <div class="gl-overflow-auto">
+ <gl-truncate :text="context.title" />
+ </div>
+ <span class="gl-flex-grow-1 gl-text-right">
+ <gl-icon :name="collapseIcon" />
+ </span>
+ </button>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue
new file mode 100644
index 00000000000..d790e61ca31
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/counter.vue
@@ -0,0 +1,48 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ props: {
+ count: {
+ type: Number,
+ required: true,
+ },
+ href: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ icon: {
+ type: String,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ariaLabel() {
+ return `${this.label} ${this.count}`;
+ },
+ component() {
+ return this.href ? 'a' : 'button';
+ },
+ },
+};
+</script>
+
+<template>
+ <component
+ :is="component"
+ :aria-label="ariaLabel"
+ :href="href"
+ class="counter gl-relative gl-display-inline-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-black-normal gl-border gl-border-gray-a-08 gl-font-sm gl-font-weight-bold"
+ >
+ <gl-icon aria-hidden="true" :name="icon" />
+ <span aria-hidden="true">{{ count }}</span>
+ </component>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue
new file mode 100644
index 00000000000..4fd6918fd6f
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue
@@ -0,0 +1,37 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+ name: 'NavItem',
+ components: {
+ GlIcon,
+ },
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <li>
+ <a
+ :href="item.link"
+ class="gl-display-flex gl-pl-3 gl-py-3 gl-line-height-normal gl-text-black-normal gl-hover-bg-t-gray-a-08"
+ >
+ <div class="gl-mr-3">
+ <slot name="icon">
+ <gl-icon v-if="item.icon" :name="item.icon" />
+ </slot>
+ </div>
+ <div class="gl-pr-3">
+ {{ item.title }}
+ <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500 gl-mt-1">
+ {{ item.subtitle }}
+ </div>
+ </div>
+ </a>
+ </li>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
new file mode 100644
index 00000000000..e2eac64f5ad
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlCollapse } from '@gitlab/ui';
+import { context } from '../mock_data';
+import UserBar from './user_bar.vue';
+import ContextSwitcherToggle from './context_switcher_toggle.vue';
+import ContextSwitcher from './context_switcher.vue';
+import BottomBar from './bottom_bar.vue';
+
+export default {
+ context,
+ components: {
+ GlCollapse,
+ UserBar,
+ ContextSwitcherToggle,
+ ContextSwitcher,
+ BottomBar,
+ },
+ props: {
+ sidebarData: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ contextSwitcherOpened: false,
+ };
+ },
+};
+</script>
+
+<template>
+ <aside
+ class="super-sidebar gl-fixed gl-bottom-0 gl-left-0 gl-display-flex gl-flex-direction-column gl-bg-gray-10 gl-border-r gl-border-gray-a-08 gl-z-index-9999"
+ data-testid="super-sidebar"
+ >
+ <user-bar :sidebar-data="sidebarData" />
+ <div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden">
+ <div class="gl-flex-grow-1 gl-overflow-auto">
+ <context-switcher-toggle :context="$options.context" :expanded="contextSwitcherOpened" />
+ <gl-collapse id="context-switcher" v-model="contextSwitcherOpened">
+ <context-switcher />
+ </gl-collapse>
+ </div>
+ <div class="gl-px-3">
+ <bottom-bar />
+ </div>
+ </div>
+ </aside>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue
new file mode 100644
index 00000000000..7ee1776bf07
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue
@@ -0,0 +1,77 @@
+<script>
+import { GlAvatar, GlDropdown, GlIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import NewNavToggle from '~/nav/components/new_nav_toggle.vue';
+import logo from '../../../../views/shared/_logo.svg';
+import Counter from './counter.vue';
+
+export default {
+ logo,
+ components: {
+ GlAvatar,
+ GlDropdown,
+ GlIcon,
+ NewNavToggle,
+ Counter,
+ },
+ i18n: {
+ issues: __('Issues'),
+ mergeRequests: __('Merge requests'),
+ todoList: __('To-Do list'),
+ },
+ directives: {
+ SafeHtml,
+ },
+ inject: ['rootPath', 'toggleNewNavEndpoint'],
+ props: {
+ sidebarData: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="user-bar">
+ <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2 gl-gap-3">
+ <div class="gl-flex-grow-1">
+ <a v-safe-html="$options.logo" :href="rootPath"></a>
+ </div>
+ <gl-dropdown variant="link" no-caret>
+ <template #button-content>
+ <gl-icon name="plus" class="gl-vertical-align-middle gl-text-black-normal" />
+ </template>
+ </gl-dropdown>
+ <button class="gl-border-none">
+ <gl-icon name="search" class="gl-vertical-align-middle" />
+ </button>
+ <gl-dropdown data-testid="user-dropdown" variant="link" no-caret>
+ <template #button-content>
+ <gl-avatar :entity-name="sidebarData.name" :src="sidebarData.avatar_url" :size="32" />
+ </template>
+ <new-nav-toggle :endpoint="toggleNewNavEndpoint" enabled />
+ </gl-dropdown>
+ </div>
+ <div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2">
+ <counter
+ icon="issues"
+ :count="sidebarData.assigned_open_issues_count"
+ :href="sidebarData.issues_dashboard_path"
+ :label="$options.i18n.issues"
+ />
+ <counter
+ icon="merge-request-open"
+ :count="sidebarData.assigned_open_merge_requests_count"
+ :label="$options.i18n.mergeRequests"
+ />
+ <counter
+ icon="todo-done"
+ :count="sidebarData.todos_pending_count"
+ href="/dashboard/todos"
+ :label="$options.i18n.todoList"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/mock_data.js b/app/assets/javascripts/super_sidebar/mock_data.js
new file mode 100644
index 00000000000..0d1ac006df7
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/mock_data.js
@@ -0,0 +1,59 @@
+import { s__ } from '~/locale';
+
+export const context = {
+ title: 'Typeahead.js',
+ link: '/',
+ avatar: 'https://gitlab.com/uploads/-/system/project/avatar/278964/project_avatar.png?width=32',
+};
+
+export const contextSwitcherItems = {
+ yourWork: { title: s__('Navigation|Your work'), link: '/', icon: 'work' },
+ recentProjects: [
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ title: 'Orange',
+ subtitle: 'tropical-tree',
+ link: '/tropical-tree',
+ avatar:
+ 'https://gitlab.com/uploads/-/system/project/avatar/4456656/pajamas-logo.png?width=64',
+ },
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ title: 'Lemon',
+ subtitle: 'tropical-tree',
+ link: '/tropical-tree',
+ avatar: 'https://gitlab.com/uploads/-/system/project/avatar/7071551/GitLab_UI.png?width=64',
+ },
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ title: 'Coconut',
+ subtitle: 'tropical-tree',
+ link: '/tropical-tree',
+ avatar:
+ 'https://gitlab.com/uploads/-/system/project/avatar/4149988/SVGs_project.png?width=64',
+ },
+ ],
+ recentGroups: [
+ {
+ title: 'Developer Evangelism at GitLab',
+ subtitle: 'tropical-tree',
+ link: '/tropical-tree',
+ avatar:
+ 'https://gitlab.com/uploads/-/system/group/avatar/10087220/rainbow_tanuki.jpg?width=64',
+ },
+ {
+ title: 'security-products',
+ subtitle: 'tropical-tree',
+ link: '/tropical-tree',
+ avatar:
+ 'https://gitlab.com/uploads/-/system/group/avatar/11932235/gitlab-icon-rgb.png?width=64',
+ },
+ {
+ title: 'Tanuki-Workshops',
+ subtitle: 'tropical-tree',
+ link: '/tropical-tree',
+ avatar:
+ 'https://gitlab.com/uploads/-/system/group/avatar/5085244/Screenshot_2019-04-29_at_16.13.07.png?width=64',
+ },
+ ],
+};
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
new file mode 100644
index 00000000000..b9c7073df8c
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import SuperSidebar from './components/super_sidebar.vue';
+
+export const initSuperSidebar = () => {
+ const el = document.querySelector('.js-super-sidebar');
+
+ if (!el) return false;
+
+ const { rootPath, sidebar, toggleNewNavEndpoint } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'SuperSidebarRoot',
+ provide: {
+ rootPath,
+ toggleNewNavEndpoint,
+ },
+ render(h) {
+ return h(SuperSidebar, {
+ props: {
+ sidebarData: JSON.parse(sidebar),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js
index 96b6e78c668..cb2bf24abc7 100644
--- a/app/assets/javascripts/syntax_highlight.js
+++ b/app/assets/javascripts/syntax_highlight.js
@@ -11,7 +11,7 @@
//
export default function syntaxHighlight($els = null) {
- if (!$els) return;
+ if (!$els || $els.length === 0) return;
const els = $els.get ? $els.get() : $els;
const handler = (el) => {
diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue
index b19f92aaeb4..c88c528a632 100644
--- a/app/assets/javascripts/terraform/components/states_table.vue
+++ b/app/assets/javascripts/terraform/components/states_table.vue
@@ -11,14 +11,14 @@ import {
} from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__, sprintf } from '~/locale';
-import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
+import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import StateActions from './states_table_actions.vue';
export default {
components: {
- CiBadge,
+ CiBadgeLink,
GlAlert,
GlBadge,
GlLink,
@@ -198,7 +198,7 @@ export default {
:id="`terraformJobStatusContainer${item.name}`"
class="gl-my-2"
>
- <ci-badge
+ <ci-badge-link
:id="`terraformJobStatus${item.name}`"
:status="pipelineDetailedStatus(item)"
class="gl-py-1"
diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue b/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue
new file mode 100644
index 00000000000..94bc15fa0d0
--- /dev/null
+++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue
@@ -0,0 +1,134 @@
+<script>
+import { GlAlert, GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { sprintf } from '~/locale';
+import { updateRepositorySize } from '~/api/projects_api';
+import {
+ ERROR_MESSAGE,
+ LEARN_MORE_LABEL,
+ USAGE_QUOTAS_LABEL,
+ TOTAL_USAGE_TITLE,
+ TOTAL_USAGE_SUBTITLE,
+ TOTAL_USAGE_DEFAULT_TEXT,
+ HELP_LINK_ARIA_LABEL,
+ RECALCULATE_REPOSITORY_LABEL,
+ projectContainerRegistryPopoverContent,
+} from '../constants';
+import getProjectStorageStatistics from '../queries/project_storage.query.graphql';
+import { parseGetProjectStorageResults } from '../utils';
+import UsageGraph from './usage_graph.vue';
+import ProjectStorageDetail from './project_storage_detail.vue';
+
+export default {
+ name: 'ProjectStorageApp',
+ components: {
+ GlAlert,
+ GlButton,
+ GlLink,
+ GlLoadingIcon,
+ UsageGraph,
+ ProjectStorageDetail,
+ },
+ inject: ['projectPath', 'helpLinks'],
+ provide: {
+ containerRegistryPopoverContent: projectContainerRegistryPopoverContent,
+ },
+ apollo: {
+ project: {
+ query: getProjectStorageStatistics,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ };
+ },
+ update(data) {
+ return parseGetProjectStorageResults(data, this.helpLinks);
+ },
+ error() {
+ this.error = ERROR_MESSAGE;
+ },
+ },
+ },
+ data() {
+ return {
+ project: {},
+ error: '',
+ loadingRecalculateSize: false,
+ };
+ },
+ computed: {
+ totalUsage() {
+ return this.project?.storage?.totalUsage || TOTAL_USAGE_DEFAULT_TEXT;
+ },
+ storageTypes() {
+ return this.project?.storage?.storageTypes || [];
+ },
+ },
+ methods: {
+ clearError() {
+ this.error = '';
+ },
+ helpLinkAriaLabel(linkTitle) {
+ return sprintf(HELP_LINK_ARIA_LABEL, {
+ linkTitle,
+ });
+ },
+ async postRecalculateSize() {
+ const alertEl = document.querySelector('.js-recalculation-started-alert');
+
+ this.loadingRecalculateSize = true;
+
+ await updateRepositorySize(this.projectPath);
+
+ this.loadingRecalculateSize = false;
+ alertEl?.classList.remove('gl-display-none');
+ },
+ },
+ LEARN_MORE_LABEL,
+ USAGE_QUOTAS_LABEL,
+ TOTAL_USAGE_TITLE,
+ TOTAL_USAGE_SUBTITLE,
+ RECALCULATE_REPOSITORY_LABEL,
+};
+</script>
+<template>
+ <gl-loading-icon v-if="$apollo.queries.project.loading" class="gl-mt-5" size="lg" />
+ <gl-alert v-else-if="error" variant="danger" @dismiss="clearError">
+ {{ error }}
+ </gl-alert>
+ <div v-else>
+ <div class="gl-pt-5 gl-px-3">
+ <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
+ <div>
+ <p class="gl-m-0 gl-font-lg gl-font-weight-bold">{{ $options.TOTAL_USAGE_TITLE }}</p>
+ <p class="gl-m-0 gl-text-gray-400">
+ {{ $options.TOTAL_USAGE_SUBTITLE }}
+ <gl-link
+ :href="helpLinks.usageQuotas"
+ target="_blank"
+ :aria-label="helpLinkAriaLabel($options.USAGE_QUOTAS_LABEL)"
+ data-testid="usage-quotas-help-link"
+ >
+ {{ $options.LEARN_MORE_LABEL }}
+ </gl-link>
+ </p>
+ </div>
+ <p class="gl-m-0 gl-font-size-h-display gl-font-weight-bold" data-testid="total-usage">
+ {{ totalUsage }}
+ </p>
+ </div>
+ </div>
+ <div v-if="project.statistics" class="gl-w-full">
+ <usage-graph :root-storage-statistics="project.statistics" :limit="0" />
+ </div>
+ <div class="gl-w-full gl-my-5">
+ <gl-button
+ :loading="loadingRecalculateSize"
+ category="secondary"
+ @click="postRecalculateSize"
+ >
+ {{ $options.RECALCULATE_REPOSITORY_LABEL }}
+ </gl-button>
+ </div>
+ <project-storage-detail :storage-types="storageTypes" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue
new file mode 100644
index 00000000000..2b97886e650
--- /dev/null
+++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue
@@ -0,0 +1,142 @@
+<script>
+import { GlIcon, GlLink, GlSprintf, GlTableLite, GlPopover } from '@gitlab/ui';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { thWidthPercent } from '~/lib/utils/table_utility';
+import { sprintf } from '~/locale';
+import {
+ HELP_LINK_ARIA_LABEL,
+ PROJECT_TABLE_LABEL_STORAGE_TYPE,
+ PROJECT_TABLE_LABEL_USAGE,
+ containerRegistryId,
+ containerRegistryPopoverId,
+ uploadsId,
+ uploadsPopoverId,
+ uploadsPopoverContent,
+} from '../constants';
+import { descendingStorageUsageSort } from '../utils';
+import StorageTypeIcon from './storage_type_icon.vue';
+
+export default {
+ name: 'ProjectStorageDetail',
+ components: {
+ GlLink,
+ GlIcon,
+ GlTableLite,
+ GlSprintf,
+ StorageTypeIcon,
+ GlPopover,
+ },
+ inject: ['containerRegistryPopoverContent'],
+ props: {
+ storageTypes: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ sizeSortedStorageTypes() {
+ const warnings = {
+ [containerRegistryId]: {
+ popoverId: containerRegistryPopoverId,
+ popoverContent: this.containerRegistryPopoverContent,
+ },
+ [uploadsId]: {
+ popoverId: uploadsPopoverId,
+ popoverContent: this.$options.i18n.uploadsPopoverContent,
+ },
+ };
+
+ return this.storageTypes
+ .map((type) => {
+ const warning = warnings[type.storageType.id] || null;
+ return {
+ warning,
+ ...type,
+ };
+ })
+ .sort(descendingStorageUsageSort('value'));
+ },
+ },
+ methods: {
+ helpLinkAriaLabel(linkTitle) {
+ return sprintf(HELP_LINK_ARIA_LABEL, {
+ linkTitle,
+ });
+ },
+ numberToHumanSize,
+ },
+ projectTableFields: [
+ {
+ key: 'storageType',
+ label: PROJECT_TABLE_LABEL_STORAGE_TYPE,
+ thClass: thWidthPercent(90),
+ },
+ {
+ key: 'value',
+ label: PROJECT_TABLE_LABEL_USAGE,
+ thClass: thWidthPercent(10),
+ },
+ ],
+ i18n: {
+ uploadsPopoverContent,
+ },
+};
+</script>
+<template>
+ <gl-table-lite :items="sizeSortedStorageTypes" :fields="$options.projectTableFields">
+ <template #cell(storageType)="{ item }">
+ <div class="gl-display-flex gl-flex-direction-row">
+ <storage-type-icon
+ :name="item.storageType.id"
+ :data-testid="`${item.storageType.id}-icon`"
+ />
+ <div>
+ <p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.storageType.id}-name`">
+ {{ item.storageType.name }}
+ <gl-link
+ v-if="item.storageType.helpPath"
+ :href="item.storageType.helpPath"
+ target="_blank"
+ :aria-label="helpLinkAriaLabel(item.storageType.name)"
+ :data-testid="`${item.storageType.id}-help-link`"
+ >
+ <gl-icon name="question" :size="12" />
+ </gl-link>
+ </p>
+ <p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`">
+ {{ item.storageType.description }}
+ </p>
+ <p v-if="item.storageType.warningMessage" class="gl-mb-0 gl-font-sm">
+ <gl-icon name="warning" :size="12" />
+ <gl-sprintf :message="item.storageType.warningMessage">
+ <template #warningLink="{ content }">
+ <gl-link :href="item.storageType.warningLink" target="_blank" class="gl-font-sm">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ </div>
+ </template>
+
+ <template #cell(value)="{ item }">
+ {{ numberToHumanSize(item.value, 1) }}
+
+ <template v-if="item.warning">
+ <gl-icon
+ :id="item.warning.popoverId"
+ name="warning"
+ class="gl-mt-2 gl-lg-mt-0 gl-lg-ml-2"
+ />
+ <gl-popover
+ triggers="hover focus"
+ placement="top"
+ :target="item.warning.popoverId"
+ :content="item.warning.popoverContent"
+ :data-testid="item.warning.popoverId"
+ />
+ </template>
+ </template>
+ </gl-table-lite>
+</template>
diff --git a/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue b/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue
new file mode 100644
index 00000000000..bc7cd42df1e
--- /dev/null
+++ b/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+ components: { GlIcon },
+ props: {
+ name: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ methods: {
+ iconName(storageTypeName) {
+ const defaultStorageTypeIcon = 'disk';
+ const storageTypeIconMap = {
+ lfsObjectsSize: 'doc-image',
+ snippetsSize: 'snippet',
+ uploadsSize: 'upload',
+ repositorySize: 'infrastructure-registry',
+ packagesSize: 'package',
+ };
+
+ return storageTypeIconMap[`${storageTypeName}`] ?? defaultStorageTypeIcon;
+ },
+ },
+};
+</script>
+<template>
+ <span
+ class="gl-display-inline-flex gl-align-items-flex-start gl-justify-content-center gl-min-w-8 gl-pr-2 gl-pt-1"
+ >
+ <gl-icon :name="iconName(name)" :size="16" class="gl-mt-1" />
+ </span>
+</template>
diff --git a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
new file mode 100644
index 00000000000..7e001685060
--- /dev/null
+++ b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
@@ -0,0 +1,170 @@
+<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { PROJECT_STORAGE_TYPES } from '../constants';
+import { descendingStorageUsageSort } from '../utils';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [glFeatureFlagMixin()],
+ props: {
+ rootStorageStatistics: {
+ required: true,
+ type: Object,
+ },
+ limit: {
+ required: true,
+ type: Number,
+ },
+ },
+ computed: {
+ storageTypes() {
+ const {
+ containerRegistrySize,
+ buildArtifactsSize,
+ pipelineArtifactsSize,
+ lfsObjectsSize,
+ packagesSize,
+ repositorySize,
+ storageSize,
+ wikiSize,
+ snippetsSize,
+ uploadsSize,
+ } = this.rootStorageStatistics;
+ const artifactsSize = buildArtifactsSize + pipelineArtifactsSize;
+
+ if (storageSize === 0) {
+ return null;
+ }
+
+ return [
+ {
+ id: 'repositorySize',
+ style: this.usageStyle(this.barRatio(repositorySize)),
+ class: 'gl-bg-data-viz-blue-500',
+ size: repositorySize,
+ },
+ {
+ id: 'lfsObjectsSize',
+ style: this.usageStyle(this.barRatio(lfsObjectsSize)),
+ class: 'gl-bg-data-viz-orange-600',
+ size: lfsObjectsSize,
+ },
+ {
+ id: 'packagesSize',
+ style: this.usageStyle(this.barRatio(packagesSize)),
+ class: 'gl-bg-data-viz-aqua-500',
+ size: packagesSize,
+ },
+ {
+ id: 'containerRegistrySize',
+ style: this.usageStyle(this.barRatio(containerRegistrySize)),
+ class: 'gl-bg-data-viz-aqua-800',
+ size: containerRegistrySize,
+ },
+ {
+ id: 'buildArtifactsSize',
+ style: this.usageStyle(this.barRatio(artifactsSize)),
+ class: 'gl-bg-data-viz-green-600',
+ size: artifactsSize,
+ },
+ {
+ id: 'wikiSize',
+ style: this.usageStyle(this.barRatio(wikiSize)),
+ class: 'gl-bg-data-viz-magenta-500',
+ size: wikiSize,
+ },
+ {
+ id: 'snippetsSize',
+ style: this.usageStyle(this.barRatio(snippetsSize)),
+ class: 'gl-bg-data-viz-orange-800',
+ size: snippetsSize,
+ },
+ {
+ id: 'uploadsSize',
+ style: this.usageStyle(this.barRatio(uploadsSize)),
+ class: 'gl-bg-data-viz-aqua-700',
+ size: uploadsSize,
+ },
+ ]
+ .filter((data) => data.size !== 0)
+ .sort(descendingStorageUsageSort('size'))
+ .map((storageType) => {
+ const storageTypeExtraData = PROJECT_STORAGE_TYPES.find(
+ (type) => storageType.id === type.id,
+ );
+ const { name, tooltip } = storageTypeExtraData || {};
+
+ return {
+ name,
+ tooltip,
+ ...storageType,
+ };
+ });
+ },
+ },
+ methods: {
+ formatSize(size) {
+ return numberToHumanSize(size);
+ },
+ usageStyle(ratio) {
+ return { flex: ratio };
+ },
+ barRatio(size) {
+ let max = this.rootStorageStatistics.storageSize;
+
+ if (this.limit !== 0 && max <= this.limit) {
+ max = this.limit;
+ }
+
+ return size / max;
+ },
+ },
+};
+</script>
+<template>
+ <div v-if="storageTypes" class="gl-display-flex gl-flex-direction-column w-100">
+ <div class="gl-h-6 gl-my-5 gl-bg-gray-50 gl-rounded-base gl-display-flex">
+ <div
+ v-for="storageType in storageTypes"
+ :key="storageType.name"
+ class="storage-type-usage gl-h-full gl-display-inline-block"
+ :class="storageType.class"
+ :style="storageType.style"
+ data-testid="storage-type-usage"
+ ></div>
+ </div>
+ <div class="row gl-mb-4">
+ <div
+ v-for="storageType in storageTypes"
+ :key="storageType.name"
+ class="col-md-auto gl-display-flex gl-align-items-center"
+ data-testid="storage-type-legend"
+ data-qa-selector="storage_type_legend"
+ >
+ <div class="gl-h-2 gl-w-5 gl-mr-2 gl-display-inline-block" :class="storageType.class"></div>
+ <span class="gl-mr-2 gl-font-weight-bold gl-font-sm">
+ {{ storageType.name }}
+ </span>
+ <span class="gl-text-gray-500 gl-font-sm">
+ {{ formatSize(storageType.size) }}
+ </span>
+ <span
+ v-if="storageType.tooltip"
+ v-gl-tooltip
+ :title="storageType.tooltip"
+ :aria-label="storageType.tooltip"
+ class="gl-ml-2"
+ >
+ <gl-icon name="question" :size="12" />
+ </span>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/usage_quotas/storage/constants.js b/app/assets/javascripts/usage_quotas/storage/constants.js
new file mode 100644
index 00000000000..fab18cefc60
--- /dev/null
+++ b/app/assets/javascripts/usage_quotas/storage/constants.js
@@ -0,0 +1,100 @@
+import { s__, __ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export const ERROR_MESSAGE = s__(
+ 'UsageQuota|Something went wrong while fetching project storage statistics',
+);
+export const LEARN_MORE_LABEL = __('Learn more.');
+export const USAGE_QUOTAS_LABEL = s__('UsageQuota|Usage Quotas');
+export const TOTAL_USAGE_TITLE = s__('UsageQuota|Usage breakdown');
+export const TOTAL_USAGE_SUBTITLE = s__(
+ 'UsageQuota|Includes artifacts, repositories, wiki, uploads, and other items.',
+);
+export const TOTAL_USAGE_DEFAULT_TEXT = __('Not applicable.');
+export const HELP_LINK_ARIA_LABEL = s__('UsageQuota|%{linkTitle} help link');
+export const RECALCULATE_REPOSITORY_LABEL = s__('UsageQuota|Recalculate repository usage');
+
+export const projectContainerRegistryPopoverContent = s__(
+ 'UsageQuotas|The project-level storage statistics for the Container Registry are directional only and do not include savings for instance-wide deduplication.',
+);
+
+export const containerRegistryId = 'containerRegistrySize';
+export const containerRegistryPopoverId = 'container-registry-popover';
+export const uploadsId = 'uploadsSize';
+export const uploadsPopoverId = 'uploads-popover';
+export const uploadsPopoverContent = s__(
+ 'NamespaceStorage|Uploads are not counted in namespace storage quotas.',
+);
+
+export const PROJECT_TABLE_LABEL_PROJECT = __('Project');
+export const PROJECT_TABLE_LABEL_STORAGE_TYPE = s__('UsageQuota|Storage type');
+export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage');
+
+export const PROJECT_STORAGE_TYPES = [
+ {
+ id: 'containerRegistrySize',
+ name: s__('UsageQuota|Container Registry'),
+ description: s__(
+ 'UsageQuota|Gitlab-integrated Docker Container Registry for storing Docker Images.',
+ ),
+ },
+ {
+ id: 'buildArtifactsSize',
+ name: s__('UsageQuota|Artifacts'),
+ description: s__('UsageQuota|Pipeline artifacts and job artifacts, created with CI/CD.'),
+ tooltip: s__('UsageQuota|Artifacts is a sum of build and pipeline artifacts.'),
+ },
+ {
+ id: 'lfsObjectsSize',
+ name: s__('UsageQuota|LFS storage'),
+ description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'),
+ },
+ {
+ id: 'packagesSize',
+ name: s__('UsageQuota|Packages'),
+ description: s__('UsageQuota|Code packages and container images.'),
+ },
+ {
+ id: 'repositorySize',
+ name: s__('UsageQuota|Repository'),
+ description: s__('UsageQuota|Git repository.'),
+ },
+ {
+ id: 'snippetsSize',
+ name: s__('UsageQuota|Snippets'),
+ description: s__('UsageQuota|Shared bits of code and text.'),
+ },
+ {
+ id: 'uploadsSize',
+ name: s__('UsageQuota|Uploads'),
+ description: s__('UsageQuota|File attachments and smaller design graphics.'),
+ },
+ {
+ id: 'wikiSize',
+ name: s__('UsageQuota|Wiki'),
+ description: s__('UsageQuota|Wiki content.'),
+ },
+];
+
+export const projectHelpPaths = {
+ containerRegistry: helpPagePath(
+ 'user/packages/container_registry/reduce_container_registry_storage',
+ ),
+ usageQuotas: helpPagePath('user/usage_quotas'),
+ usageQuotasNamespaceStorageLimit: helpPagePath('user/usage_quotas', {
+ anchor: 'namespace-storage-limit',
+ }),
+ buildArtifacts: helpPagePath('ci/pipelines/job_artifacts', {
+ anchor: 'when-job-artifacts-are-deleted',
+ }),
+ packages: helpPagePath('user/packages/package_registry/index.md', {
+ anchor: 'reduce-storage-usage',
+ }),
+ repository: helpPagePath('user/project/repository/reducing_the_repo_size_using_git'),
+ snippets: helpPagePath('user/snippets', {
+ anchor: 'reduce-snippets-repository-size',
+ }),
+ wiki: helpPagePath('administration/wikis/index.md', {
+ anchor: 'reduce-wiki-repository-size',
+ }),
+};
diff --git a/app/assets/javascripts/usage_quotas/storage/init_project_storage.js b/app/assets/javascripts/usage_quotas/storage/init_project_storage.js
new file mode 100644
index 00000000000..00cb274902d
--- /dev/null
+++ b/app/assets/javascripts/usage_quotas/storage/init_project_storage.js
@@ -0,0 +1,34 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { projectHelpPaths as helpLinks } from './constants';
+import ProjectStorageApp from './components/project_storage_app.vue';
+
+Vue.use(VueApollo);
+
+export default (containerId = 'js-project-storage-count-app') => {
+ const el = document.getElementById(containerId);
+
+ if (!el) {
+ return false;
+ }
+
+ const { projectPath } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ name: 'ProjectStorageApp',
+ provide: {
+ projectPath,
+ helpLinks,
+ },
+ render(createElement) {
+ return createElement(ProjectStorageApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql
new file mode 100644
index 00000000000..6637e5e0865
--- /dev/null
+++ b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql
@@ -0,0 +1,17 @@
+query getProjectStorageStatistics($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ statistics {
+ containerRegistrySize
+ buildArtifactsSize
+ pipelineArtifactsSize
+ lfsObjectsSize
+ packagesSize
+ repositorySize
+ snippetsSize
+ storageSize
+ uploadsSize
+ wikiSize
+ }
+ }
+}
diff --git a/app/assets/javascripts/usage_quotas/storage/utils.js b/app/assets/javascripts/usage_quotas/storage/utils.js
new file mode 100644
index 00000000000..443788f650d
--- /dev/null
+++ b/app/assets/javascripts/usage_quotas/storage/utils.js
@@ -0,0 +1,49 @@
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { PROJECT_STORAGE_TYPES } from './constants';
+
+export const getStorageTypesFromProjectStatistics = (projectStatistics, helpLinks = {}) =>
+ PROJECT_STORAGE_TYPES.reduce((types, currentType) => {
+ const helpPathKey = currentType.id.replace(`Size`, ``);
+ const helpPath = helpLinks[helpPathKey];
+
+ return types.concat({
+ storageType: {
+ ...currentType,
+ helpPath,
+ },
+ value: projectStatistics[currentType.id],
+ });
+ }, []);
+
+/**
+ * This method parses the results from `getProjectStorageStatistics` call.
+ *
+ * @param {Object} data graphql result
+ * @returns {Object}
+ */
+export const parseGetProjectStorageResults = (data, helpLinks) => {
+ const projectStatistics = data?.project?.statistics;
+ if (!projectStatistics) {
+ return {};
+ }
+ const { storageSize } = projectStatistics;
+ const storageTypes = getStorageTypesFromProjectStatistics(projectStatistics, helpLinks);
+
+ return {
+ storage: {
+ totalUsage: numberToHumanSize(storageSize, 1),
+ storageTypes,
+ },
+ statistics: projectStatistics,
+ };
+};
+
+/**
+ * Creates a sorting function to sort storage types by usage in the graph and in the table
+ *
+ * @param {string} storageUsageKey key storing value of storage usage
+ * @returns {Function} sorting function
+ */
+export function descendingStorageUsageSort(storageUsageKey) {
+ return (a, b) => b[storageUsageKey] - a[storageUsageKey];
+}
diff --git a/app/assets/javascripts/users/profile/components/report_abuse_button.vue b/app/assets/javascripts/users/profile/components/report_abuse_button.vue
new file mode 100644
index 00000000000..aabb7fde396
--- /dev/null
+++ b/app/assets/javascripts/users/profile/components/report_abuse_button.vue
@@ -0,0 +1,55 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
+
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
+
+export default {
+ name: 'ReportAbuseButton',
+ components: {
+ GlButton,
+ AbuseCategorySelector,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ i18n: {
+ reportAbuse: __('Report abuse to administrator'),
+ },
+ data() {
+ return {
+ open: false,
+ };
+ },
+ computed: {
+ buttonTooltipText() {
+ return this.$options.i18n.reportAbuse;
+ },
+ },
+ methods: {
+ openDrawer() {
+ this.open = true;
+ },
+ closeDrawer() {
+ this.open = false;
+ },
+ hideTooltips() {
+ this.$root.$emit(BV_HIDE_TOOLTIP);
+ },
+ },
+};
+</script>
+<template>
+ <span>
+ <gl-button
+ v-gl-tooltip="buttonTooltipText"
+ category="primary"
+ :aria-label="buttonTooltipText"
+ icon="error"
+ @click="openDrawer"
+ @mouseout="hideTooltips"
+ />
+ <abuse-category-selector :show-drawer="open" @close-drawer="closeDrawer" />
+ </span>
+</template>
diff --git a/app/assets/javascripts/users/profile/index.js b/app/assets/javascripts/users/profile/index.js
new file mode 100644
index 00000000000..37f8e3ac471
--- /dev/null
+++ b/app/assets/javascripts/users/profile/index.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import ReportAbuseButton from './components/report_abuse_button.vue';
+
+export const initReportAbuse = () => {
+ const el = document.getElementById('js-report-abuse');
+
+ if (!el) return false;
+
+ const { reportAbusePath, reportedUserId, reportedFromUrl } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: { reportAbusePath, reportedUserId, reportedFromUrl },
+ render(createElement) {
+ return createElement(ReportAbuseButton);
+ },
+ });
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
index e8cc9b2eb2a..7cfc9431c2a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
@@ -69,7 +69,7 @@ export default {
isCollapsible() {
if (!this.isLoadingSummary && this.loadingState !== LOADING_STATES.collapsedError) {
if (this.shouldCollapse) {
- return this.shouldCollapse();
+ return this.shouldCollapse(this.collapsedData);
}
return true;
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 6475def461a..e435dc56503 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
@@ -1,12 +1,10 @@
<script>
-import { GlButton, GlLoadingIcon, GlIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlButton,
- GlLoadingIcon,
- GlIcon,
},
props: {
title: {
@@ -32,7 +30,7 @@ export default {
computed: {
arrowIconName() {
- return this.isCollapsed ? 'chevron-lg-right' : 'chevron-lg-down';
+ return this.isCollapsed ? 'chevron-right' : 'chevron-down';
},
ariaLabel() {
return this.isCollapsed ? __('Expand') : __('Collapse');
@@ -47,7 +45,7 @@ export default {
</script>
<template>
<div class="mr-widget-extension">
- <div class="d-flex align-items-center pl-3">
+ <div class="d-flex align-items-center pl-3 gl-py-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">
@@ -57,16 +55,15 @@ export default {
</div>
<template v-else>
- <button
- class="btn-blank btn s32 square"
- type="button"
+ <gl-button
+ class="gl-mr-3"
+ size="small"
:aria-label="ariaLabel"
- :disabled="isLoading"
+ :loading="isLoading"
+ :icon="arrowIconName"
+ category="tertiary"
@click="toggleCollapsed"
- >
- <gl-loading-icon v-if="isLoading" size="sm" />
- <gl-icon v-else :name="arrowIconName" class="js-icon" />
- </button>
+ />
<template v-if="isCollapsed">
<slot name="header"></slot>
<gl-button
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
index c2a3ae361ca..20284c4a3d8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
@@ -19,20 +19,18 @@ export default {
},
step3: {
label: __('Step 3.'),
- help: __(
- 'Merge the feature branch into the target branch and fix any conflicts. %{linkStart}How do I fix them?%{linkEnd}',
- ),
+ help: __('Resolve any conflicts. %{linkStart}How do I fix them?%{linkEnd}'),
},
step4: {
label: __('Step 4.'),
- help: __('Push the target branch up to GitLab.'),
+ help: __('Push the source branch up to GitLab.'),
},
},
copyCommands: __('Copy commands'),
tip: __(
- '%{strongStart}Tip:%{strongEnd} You can also check out merge requests locally. %{linkStart}Learn more.%{linkEnd}',
+ '%{strongStart}Tip:%{strongEnd} You can also %{linkStart}check out with merge request ID%{linkEnd}.',
),
- title: __('Check out, review, and merge locally'),
+ title: __('Check out, review, and resolve locally'),
},
components: {
GlModal,
@@ -93,21 +91,11 @@ export default {
: `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`;
},
mergeInfo2() {
- return this.isFork
- ? `git fetch origin\ngit checkout ${this.escapedTargetBranch}\ngit merge --no-ff ${this.escapedForkBranch}`
- : `git fetch origin\ngit checkout ${this.escapedTargetBranch}\ngit merge --no-ff ${this.escapedSourceBranch}`;
- },
- mergeInfo3() {
- return this.canMerge
- ? `git push origin ${this.escapedTargetBranch}`
- : __('Note that pushing to GitLab requires write access to this repository.');
+ return `git push origin ${this.escapedSourceBranch}`;
},
escapedForkBranch() {
return escapeShellString(`${this.sourceProjectPath}-${this.sourceBranch}`);
},
- escapedTargetBranch() {
- return escapeShellString(this.targetBranch);
- },
escapedSourceBranch() {
return escapeShellString(this.sourceBranch);
},
@@ -145,6 +133,18 @@ export default {
class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0"
/>
</div>
+ <p v-if="reviewingDocsPath">
+ <gl-sprintf data-testid="docs-tip" :message="$options.i18n.tip">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #link="{ content }">
+ <gl-link class="gl-display-inline-block" :href="reviewingDocsPath" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
<p>
<strong>
@@ -164,14 +164,6 @@ export default {
</template>
</gl-sprintf>
</p>
- <div class="gl-display-flex">
- <pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo2 }}</pre>
- <clipboard-button
- :text="mergeInfo2"
- :title="$options.i18n.copyCommands"
- class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0"
- />
- </div>
<p>
<strong>
{{ $options.i18n.steps.step4.label }}
@@ -179,24 +171,12 @@ export default {
{{ $options.i18n.steps.step4.help }}
</p>
<div class="gl-display-flex">
- <pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo3 }}</pre>
+ <pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo2 }}</pre>
<clipboard-button
- :text="mergeInfo3"
+ :text="mergeInfo2"
:title="$options.i18n.copyCommands"
class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0"
/>
</div>
- <p v-if="reviewingDocsPath">
- <gl-sprintf data-testid="docs-tip" :message="$options.i18n.tip">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- <template #link="{ content }">
- <gl-link class="gl-display-inline-block" :href="reviewingDocsPath" target="_blank">{{
- content
- }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
</gl-modal>
</template>
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
index 2683956e603..ecf08f78f57 100644
--- 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
@@ -6,7 +6,15 @@ export default {
};
},
updated() {
- this.hasChildren = this.$scopedSlots.default?.()?.some((c) => c.tag);
+ this.hasChildren = this.checkSlots();
+ },
+ mounted() {
+ this.hasChildren = this.checkSlots();
+ },
+ methods: {
+ checkSlots() {
+ return this.$scopedSlots.default?.()?.some((c) => c.tag);
+ },
},
};
</script>
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 9a3555d3e11..f7d6f7b4345 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
@@ -76,17 +76,17 @@ export default {
<div
:class="{
'gl-display-flex gl-align-items-center': actions.length,
- 'gl-md-display-flex gl-align-items-center': !actions.length,
+ 'gl-md-display-flex gl-align-items-center gl-flex-wrap gl-gap-3': !actions.length,
}"
- class="media-body"
+ class="media-body gl-line-height-24"
>
<slot></slot>
<div
:class="{
- 'state-container-action-buttons gl-flex-direction-column gl-flex-wrap gl-justify-content-end': !actions.length,
+ 'state-container-action-buttons gl-flex-wrap gl-lg-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"
+ class="gl-display-flex gl-font-size-0 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/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index 8e1b18c63a4..a5d982fe221 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
@@ -88,25 +88,24 @@ export default {
</template>
<template v-if="!isLoading && !state.shouldBeRebased" #actions>
<gl-button
- v-if="userPermissions.canMerge"
+ v-if="showResolveButton"
+ :href="mr.conflictResolutionPath"
size="small"
variant="confirm"
- category="secondary"
- data-testid="merge-locally-button"
- class="js-check-out-modal-trigger gl-align-self-start"
- :class="{ 'gl-mr-2': showResolveButton }"
+ class="gl-align-self-start"
+ data-testid="resolve-conflicts-button"
>
- {{ s__('mrWidget|Resolve locally') }}
+ {{ s__('mrWidget|Resolve conflicts') }}
</gl-button>
<gl-button
- v-if="showResolveButton"
- :href="mr.conflictResolutionPath"
+ v-if="userPermissions.canMerge"
size="small"
variant="confirm"
- class="gl-mb-2 gl-md-mb-0 gl-align-self-start"
- data-testid="resolve-conflicts-button"
+ category="secondary"
+ data-testid="merge-locally-button"
+ class="js-check-out-modal-trigger gl-align-self-start"
>
- {{ s__('mrWidget|Resolve conflicts') }}
+ {{ s__('mrWidget|Resolve locally') }}
</gl-button>
</template>
</state-container>
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 4ae4edf02c3..d687f0346c7 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
@@ -179,27 +179,27 @@ export default {
</template>
<template v-if="!isLoading" #actions>
<gl-button
- v-if="showRebaseWithoutPipeline"
:loading="isMakingRequest"
variant="confirm"
size="small"
- category="secondary"
- data-testid="rebase-without-ci-button"
- class="gl-align-self-start gl-mr-2"
- @click="rebaseWithoutCi"
+ data-qa-selector="mr_rebase_button"
+ data-testid="standard-rebase-button"
+ class="gl-align-self-start"
+ @click="rebase"
>
- {{ __('Rebase without pipeline') }}
+ {{ __('Rebase') }}
</gl-button>
<gl-button
+ v-if="showRebaseWithoutPipeline"
:loading="isMakingRequest"
variant="confirm"
size="small"
- data-qa-selector="mr_rebase_button"
- data-testid="standard-rebase-button"
- class="gl-mb-2 gl-md-mb-0 gl-align-self-start"
- @click="rebase"
+ category="secondary"
+ data-testid="rebase-without-ci-button"
+ class="gl-align-self-start gl-mr-2"
+ @click="rebaseWithoutCi"
>
- {{ __('Rebase') }}
+ {{ __('Rebase without pipeline') }}
</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 01f9b4757a0..211fbba305f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -1,7 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
import { produce } from 'immer';
-import $ from 'jquery';
import { createAlert } from '~/flash';
import toast from '~/vue_shared/plugins/global_toast';
import { __ } from '~/locale';
@@ -111,7 +110,9 @@ export default {
},
}) => {
toast(__('Marked as ready. Merging is now allowed.'));
- $('.merge-request .detail-page-description .title').text(title);
+ document.querySelector(
+ '.merge-request .detail-page-description .title',
+ ).textContent = title;
if (!window.gon?.features?.realtimeMrStatusChange) {
eventHub.$emit('MRWidgetUpdateRequested');
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
index 18aa85484ea..5db5f1f8dcf 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
@@ -1,5 +1,11 @@
<script>
export default {
+ components: {
+ MrSecurityWidget: () =>
+ import(
+ '~/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue'
+ ),
+ },
props: {
mr: {
type: Object,
@@ -8,7 +14,9 @@ export default {
},
computed: {
widgets() {
- return [].filter((w) => w);
+ return [window.gon?.features?.refactorSecurityExtension && 'MrSecurityWidget'].filter(
+ (w) => w,
+ );
},
},
};
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 cdf35033021..7343c98938c 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
@@ -42,7 +42,8 @@ export default {
*/
value: {
type: Object,
- required: true,
+ required: false,
+ default: () => ({}),
},
loadingText: {
type: String,
@@ -56,7 +57,8 @@ export default {
},
fetchCollapsedData: {
type: Function,
- required: true,
+ required: false,
+ default: undefined,
},
fetchExpandedData: {
type: Function,
@@ -119,6 +121,12 @@ export default {
required: false,
default: null,
},
+ // When this is provided, the widget will display an error message in the summary section.
+ hasError: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -138,8 +146,17 @@ export default {
summaryStatusIcon() {
return this.summaryError ? this.$options.failedStatusIcon : this.statusIconName;
},
+ hasActionButtons() {
+ return this.actionButtons.length > 0 || Boolean(this.$scopedSlots['action-buttons']);
+ },
},
watch: {
+ hasError: {
+ handler(newValue) {
+ this.summaryError = newValue ? this.errorText : null;
+ },
+ immediate: true,
+ },
isLoading(newValue) {
this.$emit('is-loading', newValue);
},
@@ -154,7 +171,9 @@ export default {
this.telemetryHub?.viewed();
try {
- await this.fetch(this.fetchCollapsedData, FETCH_TYPE_COLLAPSED);
+ if (this.fetchCollapsedData) {
+ await this.fetch(this.fetchCollapsedData, FETCH_TYPE_COLLAPSED);
+ }
} catch {
this.summaryError = this.errorText;
}
@@ -258,7 +277,7 @@ export default {
v-if="helpPopover"
icon="information-o"
:options="helpPopover.options"
- :class="{ 'gl-mr-3': actionButtons.length > 0 }"
+ :class="{ 'gl-mr-3': hasActionButtons }"
>
<template v-if="helpPopover.content">
<p
@@ -275,12 +294,14 @@ export default {
>
</template>
</help-popover>
- <action-buttons
- v-if="actionButtons.length > 0"
- :widget="widgetName"
- :tertiary-buttons="actionButtons"
- @clickedAction="onActionClick"
- />
+ <slot name="action-buttons">
+ <action-buttons
+ v-if="actionButtons.length > 0"
+ :widget="widgetName"
+ :tertiary-buttons="actionButtons"
+ @clickedAction="onActionClick"
+ />
+ </slot>
</div>
<div
v-if="isCollapsible"
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 543136dc659..b64f9c148d1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue
@@ -79,8 +79,11 @@ export default {
</script>
<template>
<div
- class="gl-w-full gl-display-flex gl-align-items-baseline"
- :class="{ 'gl-border-t gl-py-3 gl-pl-7': level === 2 }"
+ class="gl-w-full gl-display-flex"
+ :class="{
+ 'gl-border-t gl-py-3 gl-pl-7 gl-align-items-baseline': level === 2,
+ 'gl-align-items-center': level === 3,
+ }"
>
<status-icon
v-if="statusIconName && !header"
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/constants.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/constants.js
index 03af21a5019..26c986884d3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/constants.js
@@ -1,4 +1,9 @@
-import { n__, s__, sprintf } from '~/locale';
+import { n__, s__, __, sprintf } from '~/locale';
+
+export const codeQualityPrefixes = {
+ fixed: 'fixed',
+ new: 'new',
+};
export const i18n = {
label: s__('ciReport|Code Quality'),
@@ -7,25 +12,23 @@ export const i18n = {
noChanges: s__(`ciReport|Code Quality hasn't changed.`),
prependText: s__(`ciReport|in`),
fixed: s__(`ciReport|Fixed`),
- pluralReport: (errors) =>
+ findings: (errors, prefix) =>
sprintf(
n__(
- '%{strong_start}%{errors}%{strong_end} point',
- '%{strong_start}%{errors}%{strong_end} points',
+ '%{strong_start}%{errors}%{strong_end} %{prefix} finding',
+ '%{strong_start}%{errors}%{strong_end} %{prefix} findings',
errors.length,
),
{
errors: errors.length,
+ prefix,
},
false,
),
- singularReport: (errors) => n__('%d point', '%d points', errors.length),
improvementAndDegradationCopy: (improvement, degradation) =>
- sprintf(
- s__(`ciReport|Code Quality improved on ${improvement} and degraded on ${degradation}.`),
- ),
- improvedCopy: (improvements) =>
- sprintf(s__(`ciReport|Code Quality improved on ${improvements}.`)),
- degradedCopy: (degradations) =>
- sprintf(s__(`ciReport|Code Quality degraded on ${degradations}.`)),
+ sprintf(__('Code Quality scans found %{degradation} and %{improvement}.'), {
+ improvement,
+ degradation,
+ }),
+ singularCopy: (findings) => sprintf(__('Code Quality scans found %{findings}.'), { findings }),
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js
index 394f8979a53..4f9bba1e0cb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js
@@ -4,7 +4,7 @@ import { SEVERITY_ICONS_MR_WIDGET } from '~/ci/reports/codequality_report/consta
import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status';
import { parseCodeclimateMetrics } from '~/ci/reports/codequality_report/store/utils/codequality_parser';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
-import { i18n } from './constants';
+import { i18n, codeQualityPrefixes } from './constants';
export default {
name: 'WidgetCodeQuality',
@@ -12,28 +12,36 @@ export default {
props: ['codeQuality', 'blobPath'],
i18n,
computed: {
+ shouldCollapse(data) {
+ const { newErrors, resolvedErrors, parsingInProgress } = data;
+ if (parsingInProgress || (newErrors.length === 0 && resolvedErrors.length === 0)) {
+ return false;
+ }
+ return true;
+ },
summary(data) {
- const { newErrors, resolvedErrors, errorSummary, parsingInProgress } = data;
-
+ const { newErrors, resolvedErrors, parsingInProgress } = data;
if (parsingInProgress) {
return i18n.loading;
- } else if (errorSummary.errored >= 1 && errorSummary.resolved >= 1) {
+ } else if (newErrors.length >= 1 && resolvedErrors.length >= 1) {
return i18n.improvementAndDegradationCopy(
- i18n.pluralReport(resolvedErrors),
- i18n.pluralReport(newErrors),
+ i18n.findings(resolvedErrors, codeQualityPrefixes.fixed),
+ i18n.findings(newErrors, codeQualityPrefixes.new),
);
- } else if (errorSummary.resolved >= 1) {
- return i18n.improvedCopy(i18n.singularReport(resolvedErrors));
- } else if (errorSummary.errored >= 1) {
- return i18n.degradedCopy(i18n.singularReport(newErrors));
+ } else if (resolvedErrors.length >= 1) {
+ return i18n.singularCopy(i18n.findings(resolvedErrors, codeQualityPrefixes.fixed));
+ } else if (newErrors.length >= 1) {
+ return i18n.singularCopy(i18n.findings(newErrors, codeQualityPrefixes.new));
}
return i18n.noChanges;
},
statusIcon() {
- if (this.collapsedData.errorSummary?.errored >= 1) {
+ if (this.collapsedData.newErrors.length >= 1) {
return EXTENSION_ICONS.warning;
+ } else if (this.collapsedData.resolvedErrors.length >= 1) {
+ return EXTENSION_ICONS.success;
}
- return EXTENSION_ICONS.success;
+ return EXTENSION_ICONS.neutral;
},
},
methods: {
@@ -46,8 +54,6 @@ export default {
parsingInProgress: status === HTTP_STATUS_NO_CONTENT,
resolvedErrors: parseCodeclimateMetrics(data.resolved_errors, this.blobPath.head_path),
newErrors: parseCodeclimateMetrics(data.new_errors, this.blobPath.head_path),
- existingErrors: parseCodeclimateMetrics(data.existing_errors, this.blobPath.head_path),
- errorSummary: data.summary,
},
};
});
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/graphql/security_report_merge_request_download_paths.query.graphql b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/graphql/security_report_merge_request_download_paths.query.graphql
new file mode 100644
index 00000000000..c12e4d1febb
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/graphql/security_report_merge_request_download_paths.query.graphql
@@ -0,0 +1,28 @@
+query securityReportsDownloadPaths(
+ $projectPath: ID!
+ $iid: String!
+ $reportTypes: [SecurityReportTypeEnum!]
+) {
+ project(fullPath: $projectPath) {
+ id
+ mergeRequest(iid: $iid) {
+ id
+ headPipeline {
+ id
+ jobs(securityReportTypes: $reportTypes) {
+ nodes {
+ id
+ name
+ artifacts {
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
+ nodes {
+ downloadPath
+ fileType
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue
new file mode 100644
index 00000000000..f0b20adc5cf
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue
@@ -0,0 +1,134 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import MrWidget from '~/vue_merge_request_widget/components/widget/widget.vue';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__, sprintf } from '~/locale';
+import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
+import securityReportMergeRequestDownloadPathsQuery from './graphql/security_report_merge_request_download_paths.query.graphql';
+
+export default {
+ name: 'WidgetSecurityReportsCE',
+ components: {
+ MrWidget,
+ GlDropdown,
+ GlDropdownItem,
+ },
+ directives: {
+ GlTooltip,
+ },
+ i18n: {
+ apiError: s__(
+ 'SecurityReports|Failed to get security report information. Please reload the page or try again later.',
+ ),
+ scansHaveRun: s__('SecurityReports|Security scans have run'),
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ hasError: false,
+ };
+ },
+ reportTypes: ['sast', 'secret_detection'],
+ apollo: {
+ reportArtifacts: {
+ query: securityReportMergeRequestDownloadPathsQuery,
+ variables() {
+ return {
+ projectPath: this.mr.targetProjectFullPath,
+ iid: String(this.mr.iid),
+ reportTypes: this.$options.reportTypes.map((r) => r.toUpperCase()),
+ };
+ },
+ update(data) {
+ const artifacts = [];
+
+ (data?.project?.mergeRequest?.headPipeline?.jobs?.nodes || []).forEach((reportType) => {
+ reportType.artifacts?.nodes.forEach((artifact) => {
+ if (artifact.fileType !== 'TRACE') {
+ artifacts.push({
+ name: reportType.name,
+ id: reportType.id,
+ path: artifact.downloadPath,
+ });
+ }
+ });
+ });
+
+ return artifacts;
+ },
+ error() {
+ this.hasError = true;
+ },
+ },
+ },
+ computed: {
+ artifacts() {
+ return this.reportArtifacts || [];
+ },
+ },
+ methods: {
+ handleIsLoading(value) {
+ this.isLoading = value;
+ },
+
+ artifactText({ name }) {
+ return sprintf(s__('SecurityReports|Download %{artifactName}'), {
+ artifactName: name,
+ });
+ },
+ },
+ widgetHelpPopover: {
+ options: { title: s__('ciReport|Security scan results') },
+ content: {
+ text: s__(
+ 'ciReport|New vulnerabilities are vulnerabilities that the security scan detects in the merge request that are different to existing vulnerabilities in the default branch.',
+ ),
+ learnMorePath: helpPagePath('user/application_security/index', {
+ anchor: 'view-security-scan-information-in-merge-requests',
+ }),
+ },
+ },
+ icons: EXTENSION_ICONS,
+};
+</script>
+
+<template>
+ <mr-widget
+ :has-error="hasError"
+ :error-text="$options.i18n.apiError"
+ :status-icon-name="$options.icons.warning"
+ :widget-name="$options.name"
+ :is-collapsible="false"
+ :help-popover="$options.widgetHelpPopover"
+ :summary="$options.i18n.scansHaveRun"
+ @is-loading="handleIsLoading"
+ >
+ <template v-if="artifacts.length > 0" #action-buttons>
+ <div class="gl-ml-3">
+ <gl-dropdown
+ v-gl-tooltip
+ icon="download"
+ size="small"
+ category="tertiary"
+ variant="confirm"
+ right
+ >
+ <gl-dropdown-item
+ v-for="artifact in artifacts"
+ :key="artifact.path"
+ :href="artifact.path"
+ :data-testid="`download-${artifact.name}`"
+ download
+ >
+ {{ artifactText(artifact) }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+ </template>
+ </mr-widget>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js
index 5fd5950859b..c8d969e3adf 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js
@@ -1,6 +1,6 @@
import Visibility from 'visibilityjs';
import axios from '~/lib/utils/axios_utils';
-import httpStatusCodes from '~/lib/utils/http_status';
+import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import Poll from '~/lib/utils/poll';
import * as types from './mutation_types';
@@ -63,7 +63,7 @@ export const fetchArtifacts = ({ state, dispatch }) => {
export const receiveArtifactsSuccess = ({ commit }, response) => {
// With 204 we keep polling and don't update the state
- if (response.status === httpStatusCodes.OK) {
+ if (response.status === HTTP_STATUS_OK) {
commit(types.RECEIVE_ARTIFACTS_SUCCESS, response.data);
}
};
diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
index c93057c491c..271cfd210a6 100644
--- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
@@ -66,6 +66,7 @@ export default {
<template>
<gl-link
v-gl-tooltip
+ class="gl-display-inline-flex gl-align-items-center gl-line-height-0 gl-px-3 gl-py-2 gl-rounded-base"
:class="cssClass"
:title="title"
data-qa-selector="status_badge_link"
@@ -75,7 +76,7 @@ export default {
<ci-icon :status="status" :css-classes="iconClasses" />
<template v-if="showText">
- {{ status.text }}
+ <span class="gl-ml-2">{{ status.text }}</span>
</template>
</gl-link>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
index 6a03e38a31d..47b96934420 100644
--- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
@@ -2,6 +2,7 @@
import { s__, sprintf } from '~/locale';
import SegmentedControlButtonGroup from '~/vue_shared/components/segmented_control_button_group.vue';
import CiCdAnalyticsAreaChart from './ci_cd_analytics_area_chart.vue';
+import { DEFAULT_SELECTED_CHART } from './constants';
export default {
components: {
@@ -20,7 +21,7 @@ export default {
},
data() {
return {
- selectedChart: 0,
+ selectedChart: DEFAULT_SELECTED_CHART,
};
},
computed: {
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js
index 1561674c0ad..3ac632b4690 100644
--- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js
@@ -1 +1,2 @@
export const CHART_CONTAINER_HEIGHT = 300;
+export const DEFAULT_SELECTED_CHART = 2;
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index 8bffc2479a1..0d7547d88a1 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -69,7 +69,7 @@ export default {
computed: {
wrapperStyleClasses() {
const status = this.status.group;
- return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status} gl-rounded-full gl-justify-content-center`;
+ return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status} gl-rounded-full gl-justify-content-center gl-line-height-0`;
},
icon() {
return this.isBorderless ? `${this.status.icon}_borderless` : this.status.icon;
diff --git a/app/assets/javascripts/vue_shared/components/constants.js b/app/assets/javascripts/vue_shared/components/constants.js
new file mode 100644
index 00000000000..b7ff715922d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/constants.js
@@ -0,0 +1,4 @@
+export const KEY_EDIT = 'edit';
+export const KEY_WEB_IDE = 'webide';
+export const KEY_GITPOD = 'gitpod';
+export const KEY_PIPELINE_EDITOR = 'pipeline_editor';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
index 74905dc2ae0..9c30ec67d5a 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
@@ -67,6 +67,7 @@ export default {
:suggestions="emojis"
:suggestions-loading="loading"
:get-active-token-value="getActiveEmoji"
+ value-identifier="name"
v-bind="$attrs"
@fetch-suggestions="fetchEmojis"
v-on="$listeners"
diff --git a/app/assets/javascripts/vue_shared/components/group_select/constants.js b/app/assets/javascripts/vue_shared/components/group_select/constants.js
index bc70936eb36..06537d682fe 100644
--- a/app/assets/javascripts/vue_shared/components/group_select/constants.js
+++ b/app/assets/javascripts/vue_shared/components/group_select/constants.js
@@ -1,6 +1,7 @@
import { __ } from '~/locale';
export const TOGGLE_TEXT = __('Search for a group');
+export const RESET_LABEL = __('Reset');
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
index 5db723e1e5a..d295052e2ce 100644
--- a/app/assets/javascripts/vue_shared/components/group_select/group_select.vue
+++ b/app/assets/javascripts/vue_shared/components/group_select/group_select.vue
@@ -1,26 +1,35 @@
<script>
import { debounce } from 'lodash';
-import { GlCollapsibleListbox } from '@gitlab/ui';
+import { GlFormGroup, GlAlert, GlCollapsibleListbox } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import axios from '~/lib/utils/axios_utils';
+import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_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,
+ RESET_LABEL,
FETCH_GROUPS_ERROR,
FETCH_GROUP_ERROR,
QUERY_TOO_SHORT_MESSAGE,
} from './constants';
const MINIMUM_QUERY_LENGTH = 3;
+const GROUPS_PER_PAGE = 20;
export default {
components: {
+ GlFormGroup,
+ GlAlert,
GlCollapsibleListbox,
},
props: {
+ label: {
+ type: String,
+ required: true,
+ },
inputName: {
type: String,
required: true,
@@ -54,10 +63,14 @@ export default {
return {
pristine: true,
searching: false,
+ hasMoreGroups: true,
+ infiniteScrollLoading: false,
searchString: '',
groups: [],
+ page: 1,
selectedValue: null,
selectedText: null,
+ errorMessage: '',
};
},
computed: {
@@ -74,6 +87,9 @@ export default {
toggleText() {
return this.selectedText ?? this.$options.i18n.toggleText;
},
+ resetButtonLabel() {
+ return this.clearable ? RESET_LABEL : '';
+ },
inputValue() {
return this.selectedValue ? this.selectedValue : '';
},
@@ -95,35 +111,48 @@ export default {
if (this.isSearchQueryTooShort) {
this.groups = [];
} else {
- this.fetchGroups(searchString);
+ this.fetchGroups();
}
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
- async fetchGroups(searchString = '') {
- this.searching = true;
+ async fetchGroups(page = 1) {
+ if (page === 1) {
+ this.searching = true;
+ this.groups = [];
+ this.hasMoreGroups = true;
+ } else {
+ this.infiniteScrollLoading = true;
+ }
try {
- const { data } = await axios.get(
+ const { data, headers } = await axios.get(
Api.buildUrl(groupsPath(this.groupsFilter, this.parentGroupID)),
{
params: {
- search: searchString,
+ search: this.searchString,
+ per_page: GROUPS_PER_PAGE,
+ page,
},
},
);
const groups = data.length ? data : data.results || [];
- this.groups = groups.map((group) => ({
- ...group,
- value: String(group.id),
- }));
+ this.groups.push(
+ ...groups.map((group) => ({
+ ...group,
+ value: String(group.id),
+ })),
+ );
+
+ const { totalPages } = parseIntPagination(normalizeHeaders(headers));
+ if (page === totalPages) {
+ this.hasMoreGroups = false;
+ }
+ this.page = page;
this.searching = false;
+ this.infiniteScrollLoading = false;
} catch (error) {
- createAlert({
- message: FETCH_GROUPS_ERROR,
- error,
- parent: this.$el,
- });
+ this.handleError({ message: FETCH_GROUPS_ERROR, error });
}
},
async fetchInitialSelection() {
@@ -139,11 +168,7 @@ export default {
this.pristine = false;
this.searching = false;
} catch (error) {
- createAlert({
- message: FETCH_GROUP_ERROR,
- error,
- parent: this.$el,
- });
+ this.handleError({ message: FETCH_GROUP_ERROR, error });
}
},
onShown() {
@@ -154,11 +179,20 @@ export default {
onReset() {
this.selected = null;
},
+ onBottomReached() {
+ this.fetchGroups(this.page + 1);
+ },
+ handleError({ message, error }) {
+ Sentry.captureException(error);
+ this.errorMessage = message;
+ },
+ dismissError() {
+ this.errorMessage = '';
+ },
},
i18n: {
toggleText: TOGGLE_TEXT,
selectGroup: __('Select a group'),
- reset: __('Reset'),
noResultsText: __('No results found.'),
searchQueryTooShort: QUERY_TOO_SHORT_MESSAGE,
},
@@ -166,21 +200,27 @@ export default {
</script>
<template>
- <div>
+ <gl-form-group :label="label">
+ <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{
+ errorMessage
+ }}</gl-alert>
<gl-collapsible-listbox
ref="listbox"
v-model="selected"
:header-text="$options.i18n.selectGroup"
- :reset-button-label="$options.i18n.reset"
+ :reset-button-label="resetButtonLabel"
:toggle-text="toggleText"
:loading="searching && pristine"
:searching="searching"
:items="groups"
:no-results-text="noResultsText"
+ :infinite-scroll="hasMoreGroups"
+ :infinite-scroll-loading="infiniteScrollLoading"
searchable
@shown="onShown"
@search="search"
@reset="onReset"
+ @bottom-reached="onBottomReached"
>
<template #list-item="{ item }">
<div class="gl-font-weight-bold">
@@ -189,7 +229,6 @@ export default {
<div class="gl-text-gray-300">{{ item.full_path }}</div>
</template>
</gl-collapsible-listbox>
- <div class="flash-container"></div>
<input :id="inputId" data-testid="input" type="hidden" :name="inputName" :value="inputValue" />
- </div>
+ </gl-form-group>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/group_select/init_group_selects.js b/app/assets/javascripts/vue_shared/components/group_select/init_group_selects.js
new file mode 100644
index 00000000000..dbfac8a0339
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/group_select/init_group_selects.js
@@ -0,0 +1,48 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import GroupSelect from './group_select.vue';
+
+const SELECTOR = '.js-vue-group-select';
+
+export const initGroupSelects = () => {
+ if (process.env.NODE_ENV !== 'production' && document.querySelector(SELECTOR) === null) {
+ // eslint-disable-next-line no-console
+ console.warn(`Attempted to initialize GroupSelect but '${SELECTOR}' not found in the page`);
+ }
+
+ [...document.querySelectorAll(SELECTOR)].forEach((el) => {
+ const {
+ parentId: parentGroupID,
+ groupsFilter,
+ label,
+ inputName,
+ inputId,
+ selected: initialSelection,
+ testid,
+ } = el.dataset;
+ const clearable = parseBoolean(el.dataset.clearable);
+
+ return new Vue({
+ el,
+ components: {
+ GroupSelect,
+ },
+ render(createElement) {
+ return createElement(GroupSelect, {
+ props: {
+ label,
+ inputName,
+ initialSelection,
+ parentGroupID,
+ groupsFilter,
+ inputId,
+ clearable,
+ },
+ attrs: {
+ 'data-testid': testid,
+ },
+ });
+ },
+ });
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index 3c4ae08d2f7..8e459cc21ac 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -75,6 +75,10 @@ export default {
// GraphQL returns `avatarUrl` and Rest `avatar_url`
return this.user?.avatarUrl || this.user?.avatar_url;
},
+ webUrl() {
+ // GraphQL returns `webUrl` and Rest `web_url`
+ return this.user?.webUrl || this.user?.web_url;
+ },
statusTooltipHTML() {
// Rest `status_tooltip_html` which is a ready to work
// html for the emoji and the status text inside a tooltip.
@@ -132,7 +136,7 @@ export default {
:data-user-id="userId"
:data-username="user.username"
:data-name="user.name"
- :href="user.webUrl"
+ :href="webUrl"
target="_blank"
class="js-user-link gl-vertical-align-middle gl-mx-2 gl-align-items-center"
>
diff --git a/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js b/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js
new file mode 100644
index 00000000000..ad89b78b521
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js
@@ -0,0 +1,42 @@
+import Vue from 'vue';
+import ListboxInput from '~/vue_shared/components/listbox_input/listbox_input.vue';
+
+export const initListboxInputs = () => {
+ const els = [...document.querySelectorAll('.js-listbox-input')];
+
+ els.forEach((el, index) => {
+ const { label, description, name, defaultToggleText, value = null } = el.dataset;
+ const { id } = el;
+ const items = JSON.parse(el.dataset.items);
+
+ return new Vue({
+ el,
+ name: `ListboxInputRoot${index + 1}`,
+ data() {
+ return {
+ selected: value,
+ };
+ },
+ render(createElement) {
+ return createElement(ListboxInput, {
+ on: {
+ select: (newValue) => {
+ this.selected = newValue;
+ },
+ },
+ props: {
+ label,
+ description,
+ name,
+ defaultToggleText,
+ selected: this.selected,
+ items,
+ },
+ attrs: {
+ id,
+ },
+ });
+ },
+ });
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue
index b1809e6a9f3..bc6b5d3176f 100644
--- a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue
+++ b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue
@@ -1,25 +1,37 @@
<script>
-import { GlListbox } from '@gitlab/ui';
+import { GlFormGroup, GlListbox } from '@gitlab/ui';
import { __ } from '~/locale';
-const MIN_ITEMS_COUNT_FOR_SEARCHING = 20;
+const MIN_ITEMS_COUNT_FOR_SEARCHING = 10;
export default {
i18n: {
noResultsText: __('No results found'),
},
components: {
+ GlFormGroup,
GlListbox,
},
model: GlListbox.model,
props: {
+ label: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ description: {
+ type: String,
+ required: false,
+ default: '',
+ },
name: {
type: String,
required: true,
},
defaultToggleText: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
selected: {
type: String,
@@ -30,6 +42,11 @@ export default {
type: GlListbox.props.items.type,
required: true,
},
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -37,6 +54,9 @@ export default {
};
},
computed: {
+ wrapperComponent() {
+ return this.label || this.description ? 'gl-form-group' : 'div';
+ },
allOptions() {
const allOptions = [];
@@ -95,16 +115,17 @@ export default {
</script>
<template>
- <div>
+ <component :is="wrapperComponent" :label="label" :description="description" v-bind="$attrs">
<gl-listbox
:selected="selected"
:toggle-text="toggleText"
:items="filteredItems"
:searchable="isSearchable"
:no-results-text="$options.i18n.noResultsText"
+ :disabled="disabled"
@search="search"
@select="$emit($options.model.event, $event)"
/>
<input ref="input" type="hidden" :name="name" :value="selected" />
- </div>
+ </component>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue
new file mode 100644
index 00000000000..6702a81e747
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue
@@ -0,0 +1,58 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ props: {
+ size: {
+ type: String,
+ required: false,
+ default: 'medium',
+ },
+ value: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ markdownEditorSelected() {
+ return this.value === 'markdown';
+ },
+ text() {
+ return this.markdownEditorSelected ? __('View rich text') : __('View markdown');
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown
+ category="tertiary"
+ data-qa-selector="editing_mode_switcher"
+ :size="size"
+ :text="text"
+ right
+ >
+ <gl-dropdown-item
+ is-check-item
+ :is-checked="!markdownEditorSelected"
+ @click="$emit('input', 'richText')"
+ ><div class="gl-font-weight-bold">{{ __('Rich text') }}</div>
+ <div class="gl-text-secondary">
+ {{ __('View the formatted output in real-time as you edit.') }}
+ </div>
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ is-check-item
+ :is-checked="markdownEditorSelected"
+ @click="$emit('input', 'markdown')"
+ ><div class="gl-font-weight-bold">{{ __('Markdown') }}</div>
+ <div class="gl-text-secondary">
+ {{ __('View and edit markdown, with the option to preview the formatted output.') }}
+ </div></gl-dropdown-item
+ >
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index b5f2602af5e..7b76fc3fc6d 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -122,6 +122,11 @@ export default {
required: false,
default: () => [],
},
+ showContentEditorSwitcher: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -364,6 +369,8 @@ export default {
:quick-actions-docs-path="quickActionsDocsPath"
:can-attach-file="canAttachFile"
:show-comment-tool-bar="showCommentToolBar"
+ :show-content-editor-switcher="showContentEditorSwitcher"
+ @enableContentEditor="$emit('enableContentEditor')"
/>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 89fffdedbfd..e83441e59a2 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -10,6 +10,7 @@ import {
INDENT_LINE,
OUTDENT_LINE,
} from '~/behaviors/shortcuts/keybindings';
+import { getModifierKey } from '~/constants';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale';
import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
@@ -66,6 +67,7 @@ export default {
return {
tag: '> ',
suggestPopoverVisible: false,
+ modifierKey: getModifierKey(),
};
},
computed: {
@@ -90,15 +92,6 @@ export default {
const expandText = s__('MarkdownEditor|Click to expand');
return [`<details><summary>${expandText}</summary>`, `{text}`, '</details>'].join('\n');
},
- isMac() {
- // Accessing properties using ?. to allow tests to use
- // this component without setting up window.gl.client.
- // In production, window.gl.client should always be present.
- return Boolean(window.gl?.client?.isMac);
- },
- modifierKey() {
- return this.isMac ? '⌘' : s__('KeyboardKey|Ctrl+');
- },
},
watch: {
showSuggestPopover() {
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 d01eae0308f..c53118b9f62 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
@@ -1,16 +1,13 @@
<script>
-import { GlSegmentedControl } from '@gitlab/ui';
-import { __ } from '~/locale';
-import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import axios from '~/lib/utils/axios_utils';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { EDITING_MODE_MARKDOWN_FIELD, EDITING_MODE_CONTENT_EDITOR } from '../../constants';
import MarkdownField from './field.vue';
export default {
components: {
- MarkdownField,
LocalStorageSync,
- GlSegmentedControl,
+ MarkdownField,
ContentEditor: () =>
import(
/* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue'
@@ -91,7 +88,6 @@ export default {
data() {
return {
editingMode: EDITING_MODE_MARKDOWN_FIELD,
- switchEditingControlEnabled: true,
autofocused: false,
};
},
@@ -114,19 +110,16 @@ export default {
updateMarkdownFromMarkdownField({ target }) {
this.$emit('input', target.value);
},
- enableSwitchEditingControl() {
- this.switchEditingControlEnabled = true;
- },
- disableSwitchEditingControl() {
- this.switchEditingControlEnabled = false;
- },
renderMarkdown(markdown) {
return axios.post(this.renderMarkdownPath, { text: markdown }).then(({ data }) => data.body);
},
onEditingModeChange(editingMode) {
+ this.editingMode = editingMode;
this.notifyEditingModeChange(editingMode);
},
onEditingModeRestored(editingMode) {
+ this.editingMode = editingMode;
+ this.$emit(editingMode);
this.notifyEditingModeChange(editingMode);
},
notifyEditingModeChange(editingMode) {
@@ -142,25 +135,10 @@ export default {
this.autofocused = true;
},
},
- switchEditingControlOptions: [
- { text: __('Source'), value: EDITING_MODE_MARKDOWN_FIELD },
- { text: __('Rich text'), value: EDITING_MODE_CONTENT_EDITOR },
- ],
};
</script>
<template>
<div>
- <div class="gl-display-flex gl-justify-content-start gl-mb-3">
- <gl-segmented-control
- v-model="editingMode"
- data-testid="toggle-editing-mode-button"
- data-qa-selector="editing_mode_button"
- class="gl-display-flex"
- :options="$options.switchEditingControlOptions"
- :disabled="!enableContentEditor || !switchEditingControlEnabled"
- @change="onEditingModeChange"
- />
- </div>
<local-storage-sync
v-model="editingMode"
storage-key="gl-wiki-content-editor-enabled"
@@ -176,7 +154,9 @@ export default {
:quick-actions-docs-path="quickActionsDocsPath"
:uploads-path="uploadsPath"
:enable-preview="enablePreview"
+ show-content-editor-switcher
class="bordered-box"
+ @enableContentEditor="onEditingModeChange('contentEditor')"
>
<template #textarea>
<textarea
@@ -205,10 +185,8 @@ export default {
:use-bottom-toolbar="useBottomToolbar"
@initialized="setEditorAsAutofocused"
@change="updateMarkdownFromContentEditor"
- @loading="disableSwitchEditingControl"
- @loadingSuccess="enableSwitchEditingControl"
- @loadingError="enableSwitchEditingControl"
@keydown="$emit('keydown', $event)"
+ @enableMarkdownEditor="onEditingModeChange('markdownField')"
/>
<input
:id="formFieldId"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index b5640e12541..e8be242f660 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui';
+import EditorModeDropdown from './editor_mode_dropdown.vue';
export default {
components: {
@@ -8,6 +9,7 @@ export default {
GlLoadingIcon,
GlSprintf,
GlIcon,
+ EditorModeDropdown,
},
props: {
markdownDocsPath: {
@@ -29,12 +31,24 @@ export default {
required: false,
default: true,
},
+ showContentEditorSwitcher: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
hasQuickActionsDocsPath() {
return this.quickActionsDocsPath !== '';
},
},
+ methods: {
+ handleEditorModeChanged(mode) {
+ if (mode === 'richText') {
+ this.$emit('enableContentEditor');
+ }
+ },
+ },
};
</script>
@@ -121,5 +135,12 @@ export default {
{{ __('Cancel') }}
</gl-button>
</span>
+ <editor-mode-dropdown
+ v-if="showContentEditorSwitcher"
+ size="small"
+ class="gl-float-right gl-line-height-28 gl-display-block"
+ value="markdown"
+ @input="handleEditorModeChanged"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
index 21212e82de4..c83643ca4de 100644
--- a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
+++ b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
@@ -1,6 +1,6 @@
<script>
import { GlBadge, GlTabs, GlTab } from '@gitlab/ui';
-import $ from 'jquery';
+import { initScrollingTabs } from '~/layout_nav';
/**
* Given an array of tabs, renders non linked bootstrap tabs.
@@ -41,7 +41,7 @@ export default {
},
},
mounted() {
- $(document).trigger('init.scrolling-tabs');
+ initScrollingTabs();
},
methods: {
shouldRenderBadge(count) {
diff --git a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
index 5f2a66ee0b7..e1f042f78ab 100644
--- a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
+++ b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
@@ -64,8 +64,9 @@ export default {
<template>
<gl-pagination
v-if="showPagination"
- class="justify-content-center gl-mt-3"
+ class="gl-mt-3"
v-bind="$attrs"
+ align="center"
:value="pageInfo.page"
:per-page="pageInfo.perPage"
:total-items="pageInfo.total"
diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js
deleted file mode 100644
index 88c975b97b9..00000000000
--- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import { s__, sprintf } from '~/locale';
-
-export const README_URL =
- 'https://gitlab.com/guided-explorations/aws/gitlab-runner-autoscaling-aws-asg/-/blob/main/easybuttons.md';
-
-export const CF_BASE_URL =
- 'https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?';
-
-export const TEMPLATES_BASE_URL = 'https://gl-public-templates.s3.amazonaws.com/cfn/experimental/';
-
-export const EASY_BUTTONS = [
- {
- stackName: 'linux-docker-nonspot',
- templateName:
- 'easybutton-amazon-linux-2-docker-manual-scaling-with-schedule-ondemandonly.cf.yml',
- description: s__(
- 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot.',
- ),
- moreDetails1: s__('Runners|No spot. This is the default choice for Linux Docker executor.'),
- moreDetails2: s__(
- 'Runners|A capacity of 1 enables warm HA through Auto Scaling group re-spawn. A capacity of 2 enables hot HA because the service is available even when a node is lost. A capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
- ),
- },
- {
- stackName: 'linux-docker-spotonly',
- templateName: 'easybutton-amazon-linux-2-docker-manual-scaling-with-schedule-spotonly.cf.yml',
- description: sprintf(
- s__(
- 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. %{percentage} spot.',
- ),
- { percentage: '100%' },
- ),
- moreDetails1: sprintf(s__('Runners|%{percentage} spot.'), { percentage: '100%' }),
- moreDetails2: s__(
- 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
- ),
- },
- {
- stackName: 'win2019-shell-non-spot',
- templateName: 'easybutton-windows2019-shell-manual-scaling-with-scheduling-ondemandonly.cf.yml',
- description: s__(
- 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. Non-spot.',
- ),
- moreDetails1: s__('Runners|No spot. Default choice for Windows Shell executor.'),
- moreDetails2: s__(
- 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
- ),
- },
- {
- stackName: 'win2019-shell-spot',
- templateName: 'easybutton-windows2019-shell-manual-scaling-with-scheduling-spotonly.cf.yml',
- description: sprintf(
- s__(
- 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. %{percentage} spot.',
- ),
- { percentage: '100%' },
- ),
- moreDetails1: sprintf(s__('Runners|%{percentage} spot.'), { percentage: '100%' }),
- moreDetails2: s__(
- 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
- ),
- },
-];
diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue
index eee65d90285..08acde1aefc 100644
--- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue
@@ -1,125 +1,29 @@
<script>
-import {
- GlModal,
- GlSprintf,
- GlLink,
- GlFormRadioGroup,
- GlFormRadio,
- GlAccordion,
- GlAccordionItem,
-} from '@gitlab/ui';
-import Tracking from '~/tracking';
-import { getBaseURL, objectToQuery, visitUrl } from '~/lib/utils/url_utility';
-import { __, s__ } from '~/locale';
-import { README_URL, CF_BASE_URL, TEMPLATES_BASE_URL, EASY_BUTTONS } from './constants';
+import { GlModal } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import RunnerAwsInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue';
export default {
components: {
GlModal,
- GlSprintf,
- GlLink,
- GlFormRadioGroup,
- GlFormRadio,
- GlAccordion,
- GlAccordionItem,
+ RunnerAwsInstructions,
},
- mixins: [Tracking.mixin()],
props: {
modalId: {
type: String,
required: true,
},
},
- data() {
- return {
- selected: this.$options.easyButtons[0],
- };
- },
methods: {
- borderBottom(idx) {
- return idx < this.$options.easyButtons.length - 1;
- },
- easyButtonUrl(easyButton) {
- const params = {
- templateURL: TEMPLATES_BASE_URL + easyButton.templateName,
- stackName: easyButton.stackName,
- param_3GITLABRunnerInstanceURL: getBaseURL(),
- };
- return CF_BASE_URL + objectToQuery(params);
- },
- trackCiRunnerTemplatesClick(stackName) {
- this.track('template_clicked', {
- label: stackName,
- });
- },
- handleModalPrimary() {
- this.trackCiRunnerTemplatesClick(this.selected.stackName);
- visitUrl(this.easyButtonUrl(this.selected), true);
+ onClose() {
+ this.$refs.modal.close();
},
},
- i18n: {
- title: s__('Runners|Deploy GitLab Runner in AWS'),
- instructions: s__(
- 'Runners|Select your preferred option here. In the next step, you can choose the capacity for your runner in the AWS CloudFormation console.',
- ),
- chooseRunner: s__('Runners|Choose your preferred GitLab Runner'),
- dontSeeWhatYouAreLookingFor: s__(
- "Runners|Don't see what you are looking for? See the full list of options, including a fully customizable option %{linkStart}here%{linkEnd}.",
- ),
- moreDetails: __('More Details'),
- lessDetails: __('Less Details'),
- },
- deployButton: {
- text: s__('Runners|Deploy GitLab Runner in AWS'),
- attributes: [{ variant: 'confirm' }],
- },
- closeButton: {
- text: __('Cancel'),
- attributes: [{ variant: 'default' }],
- },
- readmeUrl: README_URL,
- easyButtons: EASY_BUTTONS,
+ i18n_title: s__('Runners|Deploy GitLab Runner in AWS'),
};
</script>
<template>
- <gl-modal
- :modal-id="modalId"
- :title="$options.i18n.title"
- :action-primary="$options.deployButton"
- :action-secondary="$options.closeButton"
- size="sm"
- @primary="handleModalPrimary"
- >
- <p>{{ $options.i18n.instructions }}</p>
- <gl-form-radio-group v-model="selected" :label="$options.i18n.chooseRunner" label-sr-only>
- <gl-form-radio
- v-for="(easyButton, idx) in $options.easyButtons"
- :key="easyButton.templateName"
- :value="easyButton"
- class="gl-py-5 gl-pl-8"
- :class="{ 'gl-border-b': borderBottom(idx) }"
- >
- <div class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold">
- {{ easyButton.description }}
- <gl-accordion :header-level="3" class="gl-pt-3">
- <gl-accordion-item
- :title="$options.i18n.moreDetails"
- :title-visible="$options.i18n.lessDetails"
- class="gl-font-weight-normal"
- >
- <p class="gl-pt-2">{{ easyButton.moreDetails1 }}</p>
- <p class="gl-m-0">{{ easyButton.moreDetails2 }}</p>
- </gl-accordion-item>
- </gl-accordion>
- </div>
- </gl-form-radio>
- </gl-form-radio-group>
- <p>
- <gl-sprintf :message="$options.i18n.dontSeeWhatYouAreLookingFor">
- <template #link="{ content }">
- <gl-link :href="$options.readmeUrl" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
+ <gl-modal ref="modal" :modal-id="modalId" :title="$options.i18n_title" hide-footer size="sm">
+ <runner-aws-instructions @close="onClose" />
</gl-modal>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
index c97e191b630..3dbc5246c3d 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
@@ -1,18 +1,69 @@
-import { s__ } from '~/locale';
+import { s__, sprintf } from '~/locale';
export const REGISTRATION_TOKEN_PLACEHOLDER = '$REGISTRATION_TOKEN';
-export const INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES = {
- docker: {
- instructions: s__(
- 'Runners|To install Runner in a container follow the instructions described in the GitLab documentation',
+export const PLATFORM_DOCKER = 'docker';
+export const PLATFORM_KUBERNETES = 'kubernetes';
+
+export const AWS_README_URL =
+ 'https://gitlab.com/guided-explorations/aws/gitlab-runner-autoscaling-aws-asg/-/blob/main/easybuttons.md';
+
+export const AWS_CF_BASE_URL =
+ 'https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?';
+
+export const AWS_TEMPLATES_BASE_URL =
+ 'https://gl-public-templates.s3.amazonaws.com/cfn/experimental/';
+
+export const AWS_EASY_BUTTONS = [
+ {
+ stackName: 'linux-docker-nonspot',
+ templateName:
+ 'easybutton-amazon-linux-2-docker-manual-scaling-with-schedule-ondemandonly.cf.yml',
+ description: s__(
+ 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot.',
+ ),
+ moreDetails1: s__('Runners|No spot. This is the default choice for Linux Docker executor.'),
+ moreDetails2: s__(
+ 'Runners|A capacity of 1 enables warm HA through Auto Scaling group re-spawn. A capacity of 2 enables hot HA because the service is available even when a node is lost. A capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
+ ),
+ },
+ {
+ stackName: 'linux-docker-spotonly',
+ templateName: 'easybutton-amazon-linux-2-docker-manual-scaling-with-schedule-spotonly.cf.yml',
+ description: sprintf(
+ s__(
+ 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. %{percentage} spot.',
+ ),
+ { percentage: '100%' },
+ ),
+ moreDetails1: sprintf(s__('Runners|%{percentage} spot.'), { percentage: '100%' }),
+ moreDetails2: s__(
+ 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
+ ),
+ },
+ {
+ stackName: 'win2019-shell-non-spot',
+ templateName: 'easybutton-windows2019-shell-manual-scaling-with-scheduling-ondemandonly.cf.yml',
+ description: s__(
+ 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. Non-spot.',
+ ),
+ moreDetails1: s__('Runners|No spot. Default choice for Windows Shell executor.'),
+ moreDetails2: s__(
+ 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
),
- link: 'https://docs.gitlab.com/runner/install/docker.html',
},
- kubernetes: {
- instructions: s__(
- 'Runners|To install Runner in Kubernetes follow the instructions described in the GitLab documentation.',
+ {
+ stackName: 'win2019-shell-spot',
+ templateName: 'easybutton-windows2019-shell-manual-scaling-with-scheduling-spotonly.cf.yml',
+ description: sprintf(
+ s__(
+ 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. %{percentage} spot.',
+ ),
+ { percentage: '100%' },
+ ),
+ moreDetails1: sprintf(s__('Runners|%{percentage} spot.'), { percentage: '100%' }),
+ moreDetails2: s__(
+ 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
),
- link: 'https://docs.gitlab.com/runner/install/kubernetes.html',
},
-};
+];
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/get_runner_platforms.query.graphql
index 76f152e5453..76f152e5453 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/get_runner_platforms.query.graphql
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/get_runner_setup.query.graphql
index c0248a35e3f..c0248a35e3f 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/get_runner_setup.query.graphql
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue
new file mode 100644
index 00000000000..cafebdfe5f4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue
@@ -0,0 +1,123 @@
+<script>
+import {
+ GlButton,
+ GlSprintf,
+ GlLink,
+ GlFormRadioGroup,
+ GlFormRadio,
+ GlAccordion,
+ GlAccordionItem,
+} from '@gitlab/ui';
+import Tracking from '~/tracking';
+import { getBaseURL, objectToQuery, visitUrl } from '~/lib/utils/url_utility';
+import { __, s__ } from '~/locale';
+import {
+ AWS_README_URL,
+ AWS_CF_BASE_URL,
+ AWS_TEMPLATES_BASE_URL,
+ AWS_EASY_BUTTONS,
+} from '../constants';
+
+export default {
+ components: {
+ GlButton,
+ GlSprintf,
+ GlLink,
+ GlFormRadioGroup,
+ GlFormRadio,
+ GlAccordion,
+ GlAccordionItem,
+ },
+ mixins: [Tracking.mixin()],
+ data() {
+ return {
+ selectedIndex: 0,
+ };
+ },
+ computed: {
+ selected() {
+ return this.$options.easyButtons[this.selectedIndex];
+ },
+ },
+ methods: {
+ borderBottom(idx) {
+ return idx < this.$options.easyButtons.length - 1;
+ },
+ easyButtonUrl(easyButton) {
+ const params = {
+ templateURL: AWS_TEMPLATES_BASE_URL + easyButton.templateName,
+ stackName: easyButton.stackName,
+ param_3GITLABRunnerInstanceURL: getBaseURL(),
+ };
+ return AWS_CF_BASE_URL + objectToQuery(params);
+ },
+ trackCiRunnerTemplatesClick(stackName) {
+ this.track('template_clicked', {
+ label: stackName,
+ });
+ },
+ onOk() {
+ this.trackCiRunnerTemplatesClick(this.selected.stackName);
+ visitUrl(this.easyButtonUrl(this.selected), true);
+ },
+ onClose() {
+ this.$emit('close');
+ },
+ },
+ i18n: {
+ title: s__('Runners|Deploy GitLab Runner in AWS'),
+ instructions: s__(
+ 'Runners|Select your preferred option here. In the next step, you can choose the capacity for your runner in the AWS CloudFormation console.',
+ ),
+ chooseRunner: s__('Runners|Choose your preferred GitLab Runner'),
+ dontSeeWhatYouAreLookingFor: s__(
+ "Runners|Don't see what you are looking for? See the full list of options, including a fully customizable option %{linkStart}here%{linkEnd}.",
+ ),
+ moreDetails: __('More Details'),
+ lessDetails: __('Less Details'),
+ },
+ readmeUrl: AWS_README_URL,
+ easyButtons: AWS_EASY_BUTTONS,
+};
+</script>
+<template>
+ <div>
+ <p>{{ $options.i18n.instructions }}</p>
+ <gl-form-radio-group v-model="selectedIndex" :label="$options.i18n.chooseRunner" label-sr-only>
+ <gl-form-radio
+ v-for="(easyButton, idx) in $options.easyButtons"
+ :key="easyButton.templateName"
+ :value="idx"
+ class="gl-py-5 gl-pl-8"
+ :class="{ 'gl-border-b': borderBottom(idx) }"
+ >
+ <div class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold">
+ {{ easyButton.description }}
+ <gl-accordion :header-level="3" class="gl-pt-3">
+ <gl-accordion-item
+ :title="$options.i18n.moreDetails"
+ :title-visible="$options.i18n.lessDetails"
+ class="gl-font-weight-normal"
+ >
+ <p class="gl-pt-2">{{ easyButton.moreDetails1 }}</p>
+ <p class="gl-m-0">{{ easyButton.moreDetails2 }}</p>
+ </gl-accordion-item>
+ </gl-accordion>
+ </div>
+ </gl-form-radio>
+ </gl-form-radio-group>
+ <p>
+ <gl-sprintf :message="$options.i18n.dontSeeWhatYouAreLookingFor">
+ <template #link="{ content }">
+ <gl-link :href="$options.readmeUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <footer class="gl-display-flex gl-justify-content-end gl-pt-3 gl-gap-3">
+ <gl-button @click="onClose()">{{ __('Close') }}</gl-button>
+ <gl-button variant="confirm" @click="onOk()">
+ {{ s__('Runners|Deploy GitLab Runner in AWS') }}
+ </gl-button>
+ </footer>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue
new file mode 100644
index 00000000000..36e608a068b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue
@@ -0,0 +1,169 @@
+<script>
+import { GlButton, GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+import { REGISTRATION_TOKEN_PLACEHOLDER } from '../constants';
+import getRunnerSetupInstructionsQuery from '../graphql/get_runner_setup.query.graphql';
+
+export default {
+ components: {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlLoadingIcon,
+ ModalCopyButton,
+ },
+ props: {
+ platform: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ registrationToken: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ selectedArchitecture: this.platform?.architectures[0] || null,
+ instructions: null,
+ };
+ },
+ apollo: {
+ instructions: {
+ query: getRunnerSetupInstructionsQuery,
+ skip() {
+ return !this.platform || !this.selectedArchitecture;
+ },
+ variables() {
+ return {
+ platform: this.platform.name,
+ architecture: this.selectedArchitecture.name,
+ };
+ },
+ update(data) {
+ return data?.runnerSetup;
+ },
+ error() {
+ this.$emit('error');
+ },
+ },
+ },
+ computed: {
+ architectures() {
+ return this.platform?.architectures || [];
+ },
+ binaryUrl() {
+ return this.selectedArchitecture?.downloadLocation;
+ },
+ registerInstructionsWithToken() {
+ const { registerInstructions } = this.instructions || {};
+
+ if (this.registrationToken) {
+ return registerInstructions?.replace(
+ REGISTRATION_TOKEN_PLACEHOLDER,
+ this.registrationToken,
+ );
+ }
+ return registerInstructions;
+ },
+ },
+ watch: {
+ platform() {
+ // reset selection if architecture is not in this list
+ const arch = this.architectures.find(({ name }) => name === this.selectedArchitecture.name);
+ if (!arch) {
+ this.selectArchitecture(this.architectures[0]);
+ }
+ },
+ },
+ methods: {
+ selectArchitecture(architecture) {
+ this.selectedArchitecture = architecture;
+ },
+ onClose() {
+ this.$emit('close');
+ },
+ },
+ i18n: {
+ architecture: s__('Runners|Architecture'),
+ downloadInstallBinary: s__('Runners|Download and install binary'),
+ downloadLatestBinary: s__('Runners|Download latest binary'),
+ registerRunnerCommand: s__('Runners|Command to register runner'),
+ copyInstructions: s__('Runners|Copy instructions'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h5>
+ {{ $options.i18n.architecture }}
+ <gl-loading-icon v-if="$apollo.loading" size="sm" inline />
+ </h5>
+
+ <gl-dropdown class="gl-mb-3" :text="selectedArchitecture.name">
+ <gl-dropdown-item
+ v-for="architecture in architectures"
+ :key="architecture.name"
+ is-check-item
+ :is-checked="selectedArchitecture.name === architecture.name"
+ data-testid="architecture-dropdown-item"
+ @click="selectArchitecture(architecture)"
+ >
+ {{ architecture.name }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <div class="gl-sm-display-flex gl-align-items-center gl-mb-3">
+ <h5>{{ $options.i18n.downloadInstallBinary }}</h5>
+ <gl-button
+ v-if="binaryUrl"
+ class="gl-ml-auto"
+ :href="binaryUrl"
+ download
+ icon="download"
+ data-testid="binary-download-button"
+ >
+ {{ $options.i18n.downloadLatestBinary }}
+ </gl-button>
+ </div>
+
+ <template v-if="instructions">
+ <div class="gl-display-flex">
+ <pre
+ class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line"
+ data-testid="binary-instructions"
+ >{{ instructions.installInstructions }}</pre
+ >
+ <modal-copy-button
+ :title="$options.i18n.copyInstructions"
+ :text="instructions.installInstructions"
+ :modal-id="$options.modalId"
+ css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
+ category="tertiary"
+ />
+ </div>
+ <h5 class="gl-mb-3">{{ $options.i18n.registerRunnerCommand }}</h5>
+ <div class="gl-display-flex">
+ <pre
+ class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line"
+ data-testid="register-command"
+ >{{ registerInstructionsWithToken }}</pre
+ >
+ <modal-copy-button
+ :title="$options.i18n.copyInstructions"
+ :text="registerInstructionsWithToken"
+ :modal-id="$options.modalId"
+ css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
+ category="tertiary"
+ />
+ </div>
+ </template>
+
+ <footer class="gl-display-flex gl-justify-content-end gl-pt-3">
+ <gl-button @click="onClose()">{{ __('Close') }}</gl-button>
+ </footer>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue
new file mode 100644
index 00000000000..ff7e803af2a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlButton, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ GlIcon,
+ },
+ methods: {
+ onClose() {
+ this.$emit('close');
+ },
+ },
+ I18N_INSTRUCTIONS_TEXT: s__(
+ 'Runners|To install Runner in a container follow the instructions described in the GitLab documentation',
+ ),
+ I18N_VIEW_INSTRUCTIONS: s__('Runners|View installation instructions'),
+ HELP_URL: 'https://docs.gitlab.com/runner/install/docker.html',
+};
+</script>
+<template>
+ <div>
+ <p>
+ {{ $options.I18N_INSTRUCTIONS_TEXT }}
+ </p>
+ <gl-button :href="$options.HELP_URL">
+ <gl-icon name="external-link" />
+ {{ $options.I18N_VIEW_INSTRUCTIONS }}
+ </gl-button>
+ <footer class="gl-display-flex gl-justify-content-end gl-pt-3">
+ <gl-button @click="onClose()">{{ __('Close') }}</gl-button>
+ </footer>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue
new file mode 100644
index 00000000000..ee41dab0cec
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlButton, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ GlIcon,
+ },
+ methods: {
+ onClose() {
+ this.$emit('close');
+ },
+ },
+ I18N_INSTRUCTIONS_TEXT: s__(
+ 'Runners|To install Runner in Kubernetes follow the instructions described in the GitLab documentation.',
+ ),
+ I18N_VIEW_INSTRUCTIONS: s__('Runners|View installation instructions'),
+ HELP_URL: 'https://docs.gitlab.com/runner/install/kubernetes.html',
+};
+</script>
+<template>
+ <div>
+ <p>
+ {{ $options.I18N_INSTRUCTIONS_TEXT }}
+ </p>
+ <gl-button :href="$options.HELP_URL">
+ <gl-icon name="external-link" />
+ {{ $options.I18N_VIEW_INSTRUCTIONS }}
+ </gl-button>
+ <footer class="gl-display-flex gl-justify-content-end gl-pt-3">
+ <gl-button @click="onClose()">{{ __('Close') }}</gl-button>
+ </footer>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
index c5d3704ead9..729fe9c462c 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
@@ -12,15 +12,13 @@ import {
GlResizeObserverDirective,
} from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import { isEmpty } from 'lodash';
import { __, s__ } from '~/locale';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
-import {
- INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES,
- REGISTRATION_TOKEN_PLACEHOLDER,
-} from './constants';
-import getRunnerPlatformsQuery from './graphql/queries/get_runner_platforms.query.graphql';
-import getRunnerSetupInstructionsQuery from './graphql/queries/get_runner_setup.query.graphql';
+import getRunnerPlatformsQuery from './graphql/get_runner_platforms.query.graphql';
+import { PLATFORM_DOCKER, PLATFORM_KUBERNETES } from './constants';
+
+import RunnerCliInstructions from './instructions/runner_cli_instructions.vue';
+import RunnerDockerInstructions from './instructions/runner_docker_instructions.vue';
+import RunnerKubernetesInstructions from './instructions/runner_kubernetes_instructions.vue';
export default {
components: {
@@ -33,7 +31,7 @@ export default {
GlIcon,
GlLoadingIcon,
GlSkeletonLoader,
- ModalCopyButton,
+ RunnerDockerInstructions,
},
directives: {
GlResizeObserver: GlResizeObserverDirective,
@@ -74,27 +72,13 @@ export default {
);
},
result() {
- // If it is set and available, select the defaultSelectedPlatform.
+ // If found, select the defaultSelectedPlatform.
// Otherwise, select the first available platform
- this.selectPlatform(this.defaultPlatformName || this.platforms?.[0].name);
- },
- error() {
- this.toggleAlert(true);
- },
- },
- instructions: {
- query: getRunnerSetupInstructionsQuery,
- skip() {
- return !this.shown || !this.selectedPlatform;
- },
- variables() {
- return {
- platform: this.selectedPlatform,
- architecture: this.selectedArchitecture || '',
- };
- },
- update(data) {
- return data?.runnerSetup;
+ const platform =
+ this.platforms?.find(({ name }) => this.defaultPlatformName === name) ||
+ this.platforms?.[0];
+
+ this.selectPlatform(platform);
},
error() {
this.toggleAlert(true);
@@ -106,39 +90,23 @@ export default {
shown: false,
platforms: [],
selectedPlatform: null,
- selectedArchitecture: null,
showAlert: false,
- instructions: {},
platformsButtonGroupVertical: false,
};
},
computed: {
- instructionsEmpty() {
- return isEmpty(this.instructions);
- },
- architectures() {
- return this.platforms.find(({ name }) => name === this.selectedPlatform)?.architectures || [];
- },
- binaryUrl() {
- return this.architectures.find(({ name }) => name === this.selectedArchitecture)
- ?.downloadLocation;
- },
- instructionsWithoutArchitecture() {
- return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatform]?.instructions;
- },
- runnerInstallationLink() {
- return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatform]?.link;
- },
- registerInstructionsWithToken() {
- const { registerInstructions } = this.instructions || {};
-
- if (this.registrationToken) {
- return registerInstructions?.replace(
- REGISTRATION_TOKEN_PLACEHOLDER,
- this.registrationToken,
- );
+ instructionsComponent() {
+ if (this.selectedPlatform?.architectures?.length) {
+ return RunnerCliInstructions;
+ }
+ switch (this.selectedPlatform?.name) {
+ case PLATFORM_DOCKER:
+ return RunnerDockerInstructions;
+ case PLATFORM_KUBERNETES:
+ return RunnerKubernetesInstructions;
+ default:
+ return null;
}
- return registerInstructions;
},
},
updated() {
@@ -149,6 +117,12 @@ export default {
show() {
this.$refs.modal.show();
},
+ close() {
+ this.$refs.modal.close();
+ },
+ onClose() {
+ this.close();
+ },
onShown() {
this.shown = true;
this.refocusSelectedPlatformButton();
@@ -159,21 +133,13 @@ export default {
// get focused when setting a `defaultPlatformName`.
// This method refocuses the expected button.
// See more about this auto-focus: https://bootstrap-vue.org/docs/components/modal#auto-focus-on-open
- this.$refs[this.selectedPlatform]?.[0].$el.focus();
+ this.$refs[this.selectedPlatform?.name]?.[0].$el.focus();
},
- selectPlatform(platformName) {
- this.selectedPlatform = platformName;
-
- // Update architecture when platform changes
- const arch = this.architectures.find(({ name }) => name === this.selectedArchitecture);
- if (arch) {
- this.selectArchitecture(arch.name);
- } else {
- this.selectArchitecture(this.architectures[0]?.name);
- }
+ selectPlatform(platform) {
+ this.selectedPlatform = platform;
},
- selectArchitecture(architecture) {
- this.selectedArchitecture = architecture;
+ isPlatformSelected(platform) {
+ return this.selectedPlatform.name === platform.name;
},
toggleAlert(state) {
this.showAlert = state;
@@ -189,17 +155,9 @@ export default {
i18n: {
environment: __('Environment'),
installARunner: s__('Runners|Install a runner'),
- architecture: s__('Runners|Architecture'),
downloadInstallBinary: s__('Runners|Download and install binary'),
downloadLatestBinary: s__('Runners|Download latest binary'),
- registerRunnerCommand: s__('Runners|Command to register runner'),
fetchError: s__('Runners|An error has occurred fetching instructions'),
- copyInstructions: s__('Runners|Copy instructions'),
- viewInstallationInstructions: s__('Runners|View installation instructions'),
- },
- closeButton: {
- text: __('Close'),
- attributes: [{ variant: 'default' }],
},
};
</script>
@@ -208,8 +166,8 @@ export default {
ref="modal"
:modal-id="modalId"
:title="$options.i18n.installARunner"
- :action-secondary="$options.closeButton"
v-bind="$attrs"
+ hide-footer
v-on="$listeners"
@shown="onShown"
>
@@ -234,88 +192,23 @@ export default {
v-for="platform in platforms"
:key="platform.name"
:ref="platform.name"
- :selected="selectedPlatform === platform.name"
- @click="selectPlatform(platform.name)"
+ :selected="isPlatformSelected(platform)"
+ @click="selectPlatform(platform)"
>
{{ platform.humanReadableName }}
</gl-button>
</gl-button-group>
</div>
</template>
- <template v-if="architectures.length">
- <template v-if="selectedPlatform">
- <h5>
- {{ $options.i18n.architecture }}
- <gl-loading-icon v-if="$apollo.loading" size="sm" inline />
- </h5>
-
- <gl-dropdown class="gl-mb-3" :text="selectedArchitecture">
- <gl-dropdown-item
- v-for="architecture in architectures"
- :key="architecture.name"
- is-check-item
- :is-checked="selectedArchitecture === architecture.name"
- data-testid="architecture-dropdown-item"
- @click="selectArchitecture(architecture.name)"
- >
- {{ architecture.name }}
- </gl-dropdown-item>
- </gl-dropdown>
- <div class="gl-sm-display-flex gl-align-items-center gl-mb-3">
- <h5>{{ $options.i18n.downloadInstallBinary }}</h5>
- <gl-button
- v-if="binaryUrl"
- class="gl-ml-auto"
- :href="binaryUrl"
- download
- icon="download"
- data-testid="binary-download-button"
- >
- {{ $options.i18n.downloadLatestBinary }}
- </gl-button>
- </div>
- </template>
- <template v-if="!instructionsEmpty">
- <div class="gl-display-flex">
- <pre
- class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line"
- data-testid="binary-instructions"
- >{{ instructions.installInstructions }}</pre
- >
- <modal-copy-button
- :title="$options.i18n.copyInstructions"
- :text="instructions.installInstructions"
- :modal-id="$options.modalId"
- css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
- category="tertiary"
- />
- </div>
- <h5 class="gl-mb-3">{{ $options.i18n.registerRunnerCommand }}</h5>
- <div class="gl-display-flex">
- <pre
- class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line"
- data-testid="register-command"
- >{{ registerInstructionsWithToken }}</pre
- >
- <modal-copy-button
- :title="$options.i18n.copyInstructions"
- :text="registerInstructionsWithToken"
- :modal-id="$options.modalId"
- css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
- category="tertiary"
- />
- </div>
- </template>
- </template>
- <template v-else>
- <div>
- <p>{{ instructionsWithoutArchitecture }}</p>
- <gl-button :href="runnerInstallationLink">
- <gl-icon name="external-link" />
- {{ $options.i18n.viewInstallationInstructions }}
- </gl-button>
- </div>
- </template>
+ <keep-alive>
+ <component
+ :is="instructionsComponent"
+ :registration-token="registrationToken"
+ :platform="selectedPlatform"
+ @close="onClose"
+ @error="toggleAlert(true)"
+ />
+ </keep-alive>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
index a28460dd58e..f382ded90d7 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -140,3 +140,7 @@ export const BIDI_CHARS_CLASS_LIST = 'unicode-bidi has-tooltip';
export const BIDI_CHAR_TOOLTIP = 'Potentially unwanted character detected: Unicode BiDi Control';
export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight';
+
+// We fallback to highlighting these languages with Rouge, see the following issue for more detail:
+// https://gitlab.com/gitlab-org/gitlab/-/issues/384375#note_1212752013
+export const LEGACY_FALLBACKS = ['python'];
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 0cfee93ce5d..efafa67a733 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
@@ -11,6 +11,7 @@ import {
EVENT_LABEL_FALLBACK,
ROUGE_TO_HLJS_LANGUAGE_MAP,
LINES_PER_CHUNK,
+ LEGACY_FALLBACKS,
} from './constants';
import Chunk from './components/chunk.vue';
import { registerPlugins } from './plugins/index';
@@ -57,10 +58,11 @@ export default {
},
unsupportedLanguage() {
const supportedLanguages = Object.keys(languageLoader);
- return (
+ const unsupportedLanguage =
!supportedLanguages.includes(this.language) &&
- !supportedLanguages.includes(this.blob.language?.toLowerCase())
- );
+ !supportedLanguages.includes(this.blob.language?.toLowerCase());
+
+ return LEGACY_FALLBACKS.includes(this.language) || unsupportedLanguage;
},
totalChunks() {
return Object.keys(this.chunks).length;
diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue
index 423501265d7..247f49c1345 100644
--- a/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue
@@ -30,6 +30,11 @@ export default {
required: true,
default: () => [],
},
+ additionalClass: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
data() {
return {
@@ -96,7 +101,14 @@ export default {
:value="timezoneIdentifier || value"
type="hidden"
/>
- <gl-dropdown :text="selectedTimezoneLabel" block lazy menu-class="gl-w-full!" v-bind="$attrs">
+ <gl-dropdown
+ :text="selectedTimezoneLabel"
+ :class="additionalClass"
+ block
+ lazy
+ menu-class="gl-w-full!"
+ v-bind="$attrs"
+ >
<gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus />
<gl-dropdown-item
v-for="timezone in filteredResults"
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index 383dc27ea5e..98630512308 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -1,16 +1,13 @@
<script>
import { GlModal, GlSprintf, GlLink, GlPopover } from '@gitlab/ui';
import { s__, __ } from '~/locale';
+import { visitUrl } from '~/lib/utils/url_utility';
import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-
-export const KEY_EDIT = 'edit';
-export const KEY_WEB_IDE = 'webide';
-export const KEY_GITPOD = 'gitpod';
-export const KEY_PIPELINE_EDITOR = 'pipeline_editor';
+import { KEY_EDIT, KEY_WEB_IDE, KEY_GITPOD, KEY_PIPELINE_EDITOR } from './constants';
export const i18n = {
modal: {
@@ -221,7 +218,13 @@ export default {
this.showModal('showForkModal');
},
}
- : { href: this.webIdeUrl };
+ : {
+ href: this.webIdeUrl,
+ handle: (evt) => {
+ evt.preventDefault();
+ visitUrl(this.webIdeUrl, true);
+ },
+ };
return {
key: KEY_WEB_IDE,
diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js
index 2f85a29fb84..c93dd95a886 100644
--- a/app/assets/javascripts/vue_shared/constants.js
+++ b/app/assets/javascripts/vue_shared/constants.js
@@ -9,7 +9,7 @@ const INTERVALS = {
export const FILE_SYMLINK_MODE = '120000';
-export const SHORT_DATE_FORMAT = 'd mmm, yyyy';
+export const SHORT_DATE_FORMAT = 'mmm dd, yyyy';
export const ISO_SHORT_FORMAT = 'yyyy-mm-dd';
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
index 2fc1f935501..387fc5e0d1c 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
@@ -1,6 +1,5 @@
<script>
import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
-import $ from 'jquery';
import Autosave from '~/autosave';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
@@ -81,13 +80,13 @@ export default {
if (!titleInput || !descriptionInput) return;
- this.autosaveTitle = new Autosave($(titleInput.$el), [
+ this.autosaveTitle = new Autosave(titleInput.$el, [
document.location.pathname,
document.location.search,
'title',
]);
- this.autosaveDescription = new Autosave($(descriptionInput.$el), [
+ this.autosaveDescription = new Autosave(descriptionInput, [
document.location.pathname,
document.location.search,
'description',
diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
index b6a459f21e0..26309a25f07 100644
--- a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
@@ -34,7 +34,7 @@ export default {
:href="`#${panel.name}`"
data-qa-selector="panel_link"
:data-qa-panel-name="panel.name"
- class="new-namespace-panel gl-display-flex gl-flex-shrink-0 gl-flex-direction-column gl-lg-flex-direction-row gl-align-items-center gl-rounded-base gl-border-gray-100 gl-border-solid gl-border-1 gl-w-full gl-py-6 gl-px-8 gl-hover-text-decoration-none!"
+ class="new-namespace-panel gl-display-flex gl-flex-shrink-0 gl-flex-direction-column gl-lg-flex-direction-row gl-align-items-center gl-rounded-base gl-border-gray-100 gl-border-solid gl-border-1 gl-w-full gl-py-6 gl-px-3 gl-hover-text-decoration-none!"
@click="track('click_tab', { label: panel.name })"
>
<div
diff --git a/app/assets/javascripts/work_items/components/notes/activity_filter.vue b/app/assets/javascripts/work_items/components/notes/activity_filter.vue
new file mode 100644
index 00000000000..71784d3a807
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/activity_filter.vue
@@ -0,0 +1,113 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { __ } from '~/locale';
+import Tracking from '~/tracking';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import { ASC, DESC } from '~/notes/constants';
+import { TRACKING_CATEGORY_SHOW, WORK_ITEM_NOTES_SORT_ORDER_KEY } from '~/work_items/constants';
+
+const SORT_OPTIONS = [
+ { key: DESC, text: __('Newest first'), dataid: 'js-newest-first' },
+ { key: ASC, text: __('Oldest first'), dataid: 'js-oldest-first' },
+];
+
+export default {
+ SORT_OPTIONS,
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ LocalStorageSync,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ sortOrder: {
+ type: String,
+ default: ASC,
+ required: false,
+ },
+ loading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ persistSortOrder: true,
+ };
+ },
+ computed: {
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_track_notes_sorting',
+ property: `type_${this.workItemType}`,
+ };
+ },
+ selectedSortOption() {
+ const isSortOptionValid = this.sortOrder === ASC || this.sortOrder === DESC;
+ return isSortOptionValid ? SORT_OPTIONS.find(({ key }) => this.sortOrder === key) : ASC;
+ },
+ getDropdownSelectedText() {
+ return this.selectedSortOption.text;
+ },
+ },
+ methods: {
+ setDiscussionSortDirection(direction) {
+ this.$emit('updateSavedSortOrder', direction);
+ },
+ fetchSortedDiscussions(direction) {
+ if (this.isSortDropdownItemActive(direction)) {
+ return;
+ }
+ this.track('notes_sort_order_changed');
+ this.$emit('changeSortOrder', direction);
+ },
+ isSortDropdownItemActive(sortDir) {
+ return sortDir === this.sortOrder;
+ },
+ },
+ WORK_ITEM_NOTES_SORT_ORDER_KEY,
+};
+</script>
+
+<template>
+ <div
+ id="discussion-preferences"
+ data-testid="discussion-preferences"
+ class="gl-display-inline-block gl-vertical-align-bottom gl-w-full gl-sm-w-auto"
+ >
+ <local-storage-sync
+ :value="sortOrder"
+ :storage-key="$options.WORK_ITEM_NOTES_SORT_ORDER_KEY"
+ :persist="persistSortOrder"
+ as-string
+ @input="setDiscussionSortDirection"
+ />
+ <gl-dropdown
+ :id="`discussion-preferences-dropdown-${workItemType}`"
+ class="gl-xs-w-full"
+ size="small"
+ :text="getDropdownSelectedText"
+ :disabled="loading"
+ right
+ >
+ <div id="discussion-sort">
+ <gl-dropdown-item
+ v-for="{ text, key, dataid } in $options.SORT_OPTIONS"
+ :key="text"
+ :data-testid="dataid"
+ is-check-item
+ :is-checked="isSortDropdownItemActive(key)"
+ @click="fetchSortedDiscussions(key)"
+ >
+ {{ text }}
+ </gl-dropdown-item>
+ </div>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
new file mode 100644
index 00000000000..5efa9c94f2b
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
+import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
+import NoteBody from '~/work_items/components/notes/work_item_note_body.vue';
+import NoteHeader from '~/notes/components/note_header.vue';
+
+export default {
+ components: {
+ NoteHeader,
+ NoteBody,
+ TimelineEntryItem,
+ GlAvatarLink,
+ GlAvatar,
+ },
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ author() {
+ return this.note.author;
+ },
+ noteAnchorId() {
+ return `note_${this.note.id}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <timeline-entry-item
+ :id="noteAnchorId"
+ :class="{ 'internal-note': note.internal }"
+ :data-note-id="note.id"
+ class="note note-wrapper note-comment"
+ >
+ <div class="timeline-avatar gl-float-left">
+ <gl-avatar-link :href="author.webUrl">
+ <gl-avatar
+ :src="author.avatarUrl"
+ :entity-name="author.username"
+ :alt="author.name"
+ :size="32"
+ />
+ </gl-avatar-link>
+ </div>
+
+ <div class="timeline-content">
+ <div class="note-header">
+ <note-header :author="author" :created-at="note.createdAt" :note-id="note.id" />
+ </div>
+ <div class="timeline-discussion-body">
+ <note-body :note="note" />
+ </div>
+ </div>
+ </timeline-entry-item>
+</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue
new file mode 100644
index 00000000000..dcee8750f81
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue
@@ -0,0 +1,37 @@
+<script>
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { renderGFM } from '~/behaviors/markdown/render_gfm';
+
+export default {
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ mounted() {
+ this.renderGFM();
+ },
+ methods: {
+ renderGFM() {
+ renderGFM(this.$refs['note-body']);
+ },
+ },
+ safeHtmlConfig: {
+ ADD_TAGS: ['use', 'gl-emoji', 'copy-code'],
+ },
+};
+</script>
+
+<template>
+ <div ref="note-body" class="note-body">
+ <div
+ v-safe-html:[$options.safeHtmlConfig]="note.bodyHtml"
+ class="note-text md"
+ data-testid="work-item-note-body"
+ ></div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/work_item_comment_form.vue
new file mode 100644
index 00000000000..65042f1431d
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_comment_form.vue
@@ -0,0 +1,228 @@
+<script>
+import { GlAvatar, GlButton } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import { __, s__ } from '~/locale';
+import Tracking from '~/tracking';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+import { getWorkItemQuery, getWorkItemNotesQuery } from '../utils';
+import createNoteMutation from '../graphql/create_work_item_note.mutation.graphql';
+import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
+import WorkItemNoteSignedOut from './work_item_note_signed_out.vue';
+import WorkItemCommentLocked from './work_item_comment_locked.vue';
+
+export default {
+ constantOptions: {
+ markdownDocsPath: helpPagePath('user/markdown'),
+ avatarUrl: window.gon.current_user_avatar_url,
+ },
+ components: {
+ GlAvatar,
+ GlButton,
+ MarkdownEditor,
+ WorkItemNoteSignedOut,
+ WorkItemCommentLocked,
+ },
+ mixins: [glFeatureFlagMixin(), Tracking.mixin()],
+ props: {
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ fetchByIid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ workItem: {},
+ isEditing: false,
+ isSubmitting: false,
+ isSubmittingWithKeydown: false,
+ commentText: '',
+ };
+ },
+ apollo: {
+ workItem: {
+ query() {
+ return getWorkItemQuery(this.fetchByIid);
+ },
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ },
+ skip() {
+ return !this.queryVariables.id && !this.queryVariables.iid;
+ },
+ error() {
+ this.$emit('error', i18n.fetchError);
+ },
+ },
+ },
+ computed: {
+ signedIn() {
+ return Boolean(window.gon.current_user_id);
+ },
+ autosaveKey() {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `${this.workItemId}-comment`;
+ },
+ canEdit() {
+ // maybe this should use `NotePermissions.updateNote`, but if
+ // we don't have any notes yet, that permission isn't on WorkItem
+ return Boolean(this.workItem?.userPermissions?.updateWorkItem);
+ },
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_comment',
+ property: `type_${this.workItemType}`,
+ };
+ },
+ workItemType() {
+ return this.workItem?.workItemType?.name;
+ },
+ markdownPreviewPath() {
+ return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${
+ this.workItemType
+ }`;
+ },
+ isProjectArchived() {
+ return this.workItem?.project?.archived;
+ },
+ },
+ methods: {
+ startEditing() {
+ this.isEditing = true;
+ this.commentText = getDraft(this.autosaveKey) || '';
+ },
+ async cancelEditing() {
+ if (this.commentText) {
+ const msg = s__('WorkItem|Are you sure you want to cancel editing?');
+
+ const confirmed = await confirmAction(msg, {
+ primaryBtnText: __('Discard changes'),
+ cancelBtnText: __('Continue editing'),
+ });
+
+ if (!confirmed) {
+ return;
+ }
+ }
+
+ this.isEditing = false;
+ clearDraft(this.autosaveKey);
+ },
+ async updateWorkItem(event = {}) {
+ const { key } = event;
+
+ if (key) {
+ this.isSubmittingWithKeydown = true;
+ }
+
+ this.isSubmitting = true;
+
+ try {
+ this.track('add_work_item_comment');
+
+ const {
+ data: { createNote },
+ } = await this.$apollo.mutate({
+ mutation: createNoteMutation,
+ variables: {
+ input: {
+ noteableId: this.workItem.id,
+ body: this.commentText,
+ },
+ },
+ });
+
+ if (createNote.errors?.length) {
+ throw new Error(createNote.errors[0]);
+ }
+
+ const client = this.$apollo.provider.defaultClient;
+ client.refetchQueries({
+ include: [getWorkItemNotesQuery(this.fetchByIid)],
+ });
+
+ this.isEditing = false;
+ clearDraft(this.autosaveKey);
+ } catch (error) {
+ this.$emit('error', error.message);
+ Sentry.captureException(error);
+ }
+
+ this.isSubmitting = false;
+ },
+ setCommentText(newText) {
+ this.commentText = newText;
+ updateDraft(this.autosaveKey, this.commentText);
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="timeline-entry">
+ <work-item-note-signed-out v-if="!signedIn" />
+ <work-item-comment-locked
+ v-else-if="!canEdit"
+ :work-item-type="workItemType"
+ :is-project-archived="isProjectArchived"
+ />
+ <div v-else class="gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap">
+ <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" />
+ <form v-if="isEditing" class="common-note-form gfm-form js-main-target-form gl-flex-grow-1">
+ <markdown-editor
+ class="gl-mb-3"
+ :value="commentText"
+ :render-markdown-path="markdownPreviewPath"
+ :markdown-docs-path="$options.constantOptions.markdownDocsPath"
+ :form-field-aria-label="__('Add a comment')"
+ :form-field-placeholder="__('Write a comment or drag your files here…')"
+ form-field-id="work-item-add-comment"
+ form-field-name="work-item-add-comment"
+ enable-autocomplete
+ autofocus
+ use-bottom-toolbar
+ @input="setCommentText"
+ @keydown.meta.enter="updateWorkItem"
+ @keydown.ctrl.enter="updateWorkItem"
+ @keydown.esc="cancelEditing"
+ />
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :loading="isSubmitting"
+ @click="updateWorkItem"
+ >{{ __('Comment') }}
+ </gl-button>
+ <gl-button category="tertiary" class="gl-ml-3" @click="cancelEditing"
+ >{{ __('Cancel') }}
+ </gl-button>
+ </form>
+ <gl-button
+ v-else
+ class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!"
+ @click="startEditing"
+ >{{ __('Add a comment') }}</gl-button
+ >
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_comment_locked.vue b/app/assets/javascripts/work_items/components/work_item_comment_locked.vue
new file mode 100644
index 00000000000..f837d025b7f
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_comment_locked.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlLink, GlIcon } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { TASK_TYPE_NAME } from '~/work_items/constants';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ },
+ props: {
+ workItemType: {
+ required: false,
+ type: String,
+ default: TASK_TYPE_NAME,
+ },
+ isProjectArchived: {
+ required: false,
+ type: Boolean,
+ default: false,
+ },
+ },
+ constantOptions: {
+ archivedProjectDocsPath: helpPagePath('user/project/settings/index.md', {
+ anchor: 'archive-a-project',
+ }),
+ lockedIssueDocsPath: helpPagePath('user/discussions/index.md', {
+ anchor: 'prevent-comments-by-locking-the-discussion',
+ }),
+ projectArchivedWarning: __('This project is archived and cannot be commented on.'),
+ },
+ computed: {
+ issuableDisplayName() {
+ return this.workItemType.replace(/_/g, ' ');
+ },
+ lockedIssueWarning() {
+ return sprintf(
+ __('This %{issuableDisplayName} is locked. Only project members can comment.'),
+ { issuableDisplayName: this.issuableDisplayName },
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="disabled-comment text-center">
+ <span class="issuable-note-warning gl-display-inline-block">
+ <gl-icon name="lock" class="gl-mr-2" />
+ <template v-if="isProjectArchived">
+ {{ $options.constantOptions.projectArchivedWarning }}
+ <gl-link :href="$options.constantOptions.archivedProjectDocsPath" class="learn-more">
+ {{ __('Learn more') }}
+ </gl-link>
+ </template>
+
+ <template v-else>
+ {{ lockedIssueWarning }}
+ <gl-link :href="$options.constantOptions.lockedIssueDocsPath" class="learn-more">
+ {{ __('Learn more') }}
+ </gl-link>
+ </template>
+ </span>
+ </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 cb45a05de89..ade954b2a7f 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -15,10 +15,14 @@ import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg
import * as Sentry from '@sentry/browser';
import { s__ } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils';
-import { getParameterByName } from '~/lib/utils/url_utility';
+import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url_utility';
+import { isPositiveInteger } from '~/lib/utils/number_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import {
+ sprintfWorkItem,
i18n,
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_LABELS,
@@ -53,6 +57,7 @@ import WorkItemAssignees from './work_item_assignees.vue';
import WorkItemLabels from './work_item_labels.vue';
import WorkItemMilestone from './work_item_milestone.vue';
import WorkItemNotes from './work_item_notes.vue';
+import WorkItemDetailModal from './work_item_detail_modal.vue';
export default {
i18n,
@@ -83,6 +88,7 @@ export default {
WorkItemMilestone,
WorkItemTree,
WorkItemNotes,
+ WorkItemDetailModal,
},
mixins: [glFeatureFlagMixin()],
inject: ['fullPath'],
@@ -109,11 +115,16 @@ export default {
},
},
data() {
+ const workItemId = getParameterByName('work_item_id');
+
return {
error: undefined,
updateError: undefined,
workItem: {},
updateInProgress: false,
+ modalWorkItemId: isPositiveInteger(workItemId)
+ ? convertToGraphQLId(TYPE_WORK_ITEM, workItemId)
+ : null,
};
},
apollo: {
@@ -207,6 +218,9 @@ export default {
canDelete() {
return this.workItem?.userPermissions?.deleteWorkItem;
},
+ confidentialTooltip() {
+ return sprintfWorkItem(this.$options.i18n.confidentialTooltip, this.workItemType);
+ },
fullPath() {
return this.workItem?.project.fullPath;
},
@@ -295,6 +309,11 @@ export default {
return widgetHierarchy.children.nodes;
},
},
+ mounted() {
+ if (this.modalWorkItemId) {
+ this.openInModal(undefined, { id: this.modalWorkItemId });
+ }
+ },
methods: {
isWidgetPresent(type) {
return this.workItem?.widgets?.find((widget) => widget.type === type);
@@ -362,9 +381,10 @@ export default {
});
const newData = produce(sourceData, (draftState) => {
- const widgetHierarchy = draftState.workItem.widgets.find(
- (widget) => widget.type === WIDGET_TYPE_HIERARCHY,
- );
+ const widgets = this.fetchByIid
+ ? draftState.workspace.workItems.nodes[0].widgets
+ : draftState.workItem.widgets;
+ const widgetHierarchy = widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY);
const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId);
@@ -419,6 +439,26 @@ export default {
Sentry.captureException(error);
}
},
+ updateUrl(modalWorkItemId) {
+ updateHistory({
+ url: setUrlParams({ work_item_id: getIdFromGraphQLId(modalWorkItemId) }),
+ replace: true,
+ });
+ },
+ openInModal(event, modalWorkItem) {
+ if (event) {
+ event.preventDefault();
+
+ this.updateUrl(modalWorkItem.id);
+ }
+
+ if (this.isModal) {
+ this.$emit('update-modal', event, modalWorkItem.id);
+ return;
+ }
+ this.modalWorkItemId = modalWorkItem.id;
+ this.$refs.modal.show();
+ },
},
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
};
@@ -456,6 +496,7 @@ export default {
category="tertiary"
:href="parentUrl"
:title="parentWorkItem.title"
+ @click="openInModal($event, parentWorkItem)"
>{{ parentWorkItem.title }}</gl-button
>
<gl-icon name="chevron-right" :size="16" class="gl-flex-shrink-0" />
@@ -482,7 +523,7 @@ export default {
<gl-badge
v-if="workItem.confidential"
v-gl-tooltip.bottom
- :title="$options.i18n.confidentialTooltip"
+ :title="confidentialTooltip"
variant="warning"
icon="eye-slash"
class="gl-mr-3 gl-cursor-help"
@@ -605,6 +646,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"
/>
<work-item-description
@@ -619,20 +663,24 @@ export default {
<work-item-tree
v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE"
:work-item-type="workItemType"
+ :parent-work-item-type="workItem.workItemType.name"
:work-item-id="workItem.id"
:children="children"
:can-update="canUpdate"
:project-path="fullPath"
+ :confidential="workItem.confidential"
@addWorkItemChild="addChild"
@removeChild="removeChild"
+ @show-modal="openInModal"
/>
- <template v-if="workItemsMvc2Enabled">
+ <template v-if="workItemsMvcEnabled">
<work-item-notes
v-if="workItemNotes"
:work-item-id="workItem.id"
:query-variables="queryVariables"
:full-path="fullPath"
:fetch-by-iid="fetchByIid"
+ :work-item-type="workItemType"
class="gl-pt-5"
@error="updateError = $event"
/>
@@ -644,5 +692,12 @@ export default {
:svg-path="noAccessSvgPath"
/>
</template>
+ <work-item-detail-modal
+ v-if="!isModal"
+ ref="modal"
+ :work-item-id="modalWorkItemId"
+ :show="true"
+ @close="updateUrl"
+ />
</section>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
index e8726814aaf..faea80a9de8 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
@@ -3,7 +3,6 @@ import { GlAlert, GlModal } from '@gitlab/ui';
import { s__ } from '~/locale';
import deleteWorkItemFromTaskMutation from '../graphql/delete_task_from_work_item.mutation.graphql';
import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql';
-import WorkItemDetail from './work_item_detail.vue';
export default {
i18n: {
@@ -12,7 +11,7 @@ export default {
components: {
GlAlert,
GlModal,
- WorkItemDetail,
+ WorkItemDetail: () => import('./work_item_detail.vue'),
},
props: {
workItemId: {
@@ -46,12 +45,18 @@ export default {
default: null,
},
},
- emits: ['workItemDeleted', 'close'],
+ emits: ['workItemDeleted', 'close', 'update-modal'],
data() {
return {
error: undefined,
+ updatedWorkItemId: null,
};
},
+ computed: {
+ displayedWorkItemId() {
+ return this.updatedWorkItemId || this.workItemId;
+ },
+ },
methods: {
deleteWorkItem() {
if (this.lockVersion != null && this.lineNumberStart && this.lineNumberEnd) {
@@ -116,6 +121,7 @@ export default {
});
},
closeModal() {
+ this.updatedWorkItemId = null;
this.error = '';
this.$emit('close');
},
@@ -128,6 +134,10 @@ export default {
show() {
this.$refs.modal.show();
},
+ updateModal($event, workItemId) {
+ this.updatedWorkItemId = workItemId;
+ this.$emit('update-modal', $event, workItemId);
+ },
},
};
</script>
@@ -149,11 +159,12 @@ export default {
<work-item-detail
is-modal
:work-item-parent-id="issueGid"
- :work-item-id="workItemId"
+ :work-item-id="displayedWorkItemId"
:work-item-iid="workItemIid"
- class="gl-p-5 gl-mt-n3"
+ class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolate"
@close="hide"
@deleteWorkItem="deleteWorkItem"
+ @update-modal="updateModal"
/>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js
index edad0e9b616..a7405b6d86c 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
@@ -18,6 +18,8 @@ export default function initWorkItemLinks() {
iid,
wiHasIterationsFeature,
wiHasIssuableHealthStatusFeature,
+ registerPath,
+ signInPath,
} = workItemLinksRoot.dataset;
// eslint-disable-next-line no-new
@@ -35,6 +37,8 @@ export default function initWorkItemLinks() {
hasIssueWeightsFeature: wiHasIssueWeightsFeature,
hasIterationsFeature: wiHasIterationsFeature,
hasIssuableHealthStatusFeature: wiHasIssuableHealthStatusFeature,
+ registerPath,
+ signInPath,
},
render: (createElement) =>
createElement('work-item-links', {
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue
index 763f2f338a3..3a3a846bce5 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue
@@ -5,11 +5,14 @@ import { __, s__ } from '~/locale';
import { createAlert } from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue';
+import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue';
import {
STATE_OPEN,
TASK_TYPE_NAME,
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
+ WIDGET_TYPE_PROGRESS,
+ WIDGET_TYPE_HEALTH_STATUS,
WIDGET_TYPE_MILESTONE,
WIDGET_TYPE_HIERARCHY,
WIDGET_TYPE_ASSIGNEES,
@@ -17,7 +20,6 @@ import {
WORK_ITEM_NAME_TO_ICON_MAP,
} from '../../constants';
import getWorkItemTreeQuery from '../../graphql/work_item_tree.query.graphql';
-import WorkItemLinkChildMetadata from './work_item_link_child_metadata.vue';
import WorkItemLinksMenu from './work_item_links_menu.vue';
import WorkItemTreeChildren from './work_item_tree_children.vue';
@@ -73,8 +75,15 @@ export default {
canHaveChildren() {
return this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE;
},
- allowsScopedLabels() {
- return this.getWidgetByType(this.childItem, WIDGET_TYPE_LABELS)?.allowsScopedLabels;
+ metadataWidgets() {
+ return this.childItem.widgets?.reduce((metadataWidgets, widget) => {
+ // Skip Hierarchy widget as it is not part of metadata.
+ if (widget.type && widget.type !== WIDGET_TYPE_HIERARCHY) {
+ // eslint-disable-next-line no-param-reassign
+ metadataWidgets[widget.type] = widget;
+ }
+ return metadataWidgets;
+ }, {});
},
isItemOpen() {
return this.childItem.state === STATE_OPEN;
@@ -113,16 +122,16 @@ export default {
return this.isExpanded ? __('Collapse') : __('Expand');
},
hasMetadata() {
- return this.milestone || this.assignees.length > 0 || this.labels.length > 0;
- },
- milestone() {
- return this.getWidgetByType(this.childItem, WIDGET_TYPE_MILESTONE)?.milestone;
- },
- assignees() {
- return this.getWidgetByType(this.childItem, WIDGET_TYPE_ASSIGNEES)?.assignees?.nodes || [];
- },
- labels() {
- return this.getWidgetByType(this.childItem, WIDGET_TYPE_LABELS)?.labels?.nodes || [];
+ if (this.metadataWidgets) {
+ return (
+ Number.isInteger(this.metadataWidgets[WIDGET_TYPE_PROGRESS]?.progress) ||
+ Boolean(this.metadataWidgets[WIDGET_TYPE_HEALTH_STATUS]?.healthStatus) ||
+ Boolean(this.metadataWidgets[WIDGET_TYPE_MILESTONE]?.milestone) ||
+ this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes.length > 0 ||
+ this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes.length > 0
+ );
+ }
+ return false;
},
},
methods: {
@@ -230,10 +239,7 @@ export default {
</div>
<work-item-link-child-metadata
v-if="hasMetadata"
- :allows-scoped-labels="allowsScopedLabels"
- :milestone="milestone"
- :assignees="assignees"
- :labels="labels"
+ :metadata-widgets="metadataWidgets"
class="gl-mt-3"
/>
</div>
@@ -258,6 +264,7 @@ export default {
:work-item-type="workItemType"
:children="children"
@removeChild="fetchChildren"
+ @click="$emit('click', $event)"
/>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
index 7be7e1f3496..6974804523a 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
@@ -6,6 +6,8 @@ import { isScopedLabel } from '~/lib/utils/common_utils';
import ItemMilestone from '~/issuable/components/issue_milestone.vue';
+import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS } from '../../constants';
+
export default {
components: {
GlLabel,
@@ -18,28 +20,25 @@ export default {
GlTooltip: GlTooltipDirective,
},
props: {
- allowsScopedLabels: {
- type: Boolean,
- required: false,
- default: false,
- },
- milestone: {
+ metadataWidgets: {
type: Object,
required: false,
- default: null,
- },
- assignees: {
- type: Array,
- required: false,
- default: () => [],
- },
- labels: {
- type: Array,
- required: false,
- default: () => [],
+ default: () => ({}),
},
},
computed: {
+ milestone() {
+ return this.metadataWidgets[WIDGET_TYPE_MILESTONE]?.milestone;
+ },
+ assignees() {
+ return this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes || [];
+ },
+ labels() {
+ return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || [];
+ },
+ allowsScopedLabels() {
+ return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels;
+ },
assigneesCollapsedTooltip() {
if (this.assignees.length > 2) {
return sprintf(s__('WorkItem|%{count} more assignees'), {
@@ -56,12 +55,6 @@ export default {
}
return '';
},
- labelsContainerClass() {
- if (this.milestone || this.assignees.length) {
- return 'gl-sm-ml-5';
- }
- return '';
- },
},
methods: {
showScopedLabel(label) {
@@ -73,6 +66,7 @@ export default {
<template>
<div class="gl-display-flex gl-flex-wrap gl-align-items-center">
+ <slot></slot>
<item-milestone
v-if="milestone"
:milestone="milestone"
@@ -87,6 +81,7 @@ export default {
badge-tooltip-prop="name"
:badge-sr-only-text="assigneesCollapsedTooltip"
:class="assigneesContainerClass"
+ class="gl-mr-5"
>
<template #avatar="{ avatar }">
<gl-avatar-link v-gl-tooltip target="blank" :href="avatar.webUrl" :title="avatar.name">
@@ -94,7 +89,7 @@ export default {
</gl-avatar-link>
</template>
</gl-avatars-inline>
- <div v-if="labels.length" class="gl-display-flex gl-flex-wrap" :class="labelsContainerClass">
+ <div v-if="labels.length" class="gl-display-flex gl-flex-wrap">
<gl-label
v-for="label in labels"
:key="label.id"
@@ -102,7 +97,7 @@ export default {
:background-color="label.color"
:description="label.description"
:scoped="showScopedLabel(label)"
- class="gl-mt-2 gl-sm-mt-0 gl-mr-2 gl-mb-auto gl-label-sm"
+ class="gl-mt-3 gl-sm-mt-0 gl-mr-2 gl-mb-auto gl-label-sm"
tooltip-placement="top"
/>
</div>
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 faadb5fa6fa..b078711ec5d 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
@@ -399,6 +399,7 @@ export default {
:parent-iteration="issuableIteration"
:parent-milestone="issuableMilestone"
:form-type="formType"
+ :parent-work-item-type="workItem.workItemType.name"
@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 5cf0c4154bb..d79aaab38f2 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,9 +1,18 @@
<script>
-import { GlAlert, GlFormGroup, GlForm, GlTokenSelector, GlButton, GlFormInput } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlFormGroup,
+ GlForm,
+ GlTokenSelector,
+ GlButton,
+ GlFormInput,
+ GlFormCheckbox,
+ GlTooltip,
+} 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 { __, s__, sprintf } 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';
@@ -17,6 +26,8 @@ import {
I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER,
I18N_WORK_ITEM_ADD_BUTTON_LABEL,
I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL,
+ I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL,
+ I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP,
sprintfWorkItem,
} from '../../constants';
@@ -28,6 +39,8 @@ export default {
GlButton,
GlFormGroup,
GlFormInput,
+ GlFormCheckbox,
+ GlTooltip,
},
mixins: [glFeatureFlagMixin()],
inject: ['projectPath', 'hasIterationsFeature'],
@@ -61,6 +74,11 @@ export default {
type: String,
required: true,
},
+ parentWorkItemType: {
+ type: String,
+ required: false,
+ default: '',
+ },
childrenType: {
type: String,
required: false,
@@ -108,6 +126,7 @@ export default {
error: null,
childToCreateTitle: null,
workItemsToAdd: [],
+ confidential: this.parentConfidential,
};
},
computed: {
@@ -119,7 +138,7 @@ export default {
hierarchyWidget: {
parentId: this.issuableGid,
},
- confidential: this.parentConfidential,
+ confidential: this.parentConfidential || this.confidential,
};
if (this.parentMilestoneId) {
@@ -154,6 +173,9 @@ export default {
childrenTypeName() {
return WORK_ITEMS_TYPE_MAP[this.childrenType]?.name;
},
+ childrenTypeValue() {
+ return WORK_ITEMS_TYPE_MAP[this.childrenType]?.value;
+ },
addOrCreateButtonLabel() {
if (this.isCreateForm) {
return sprintfWorkItem(I18N_WORK_ITEM_CREATE_BUTTON_LABEL, this.childrenTypeName);
@@ -162,11 +184,24 @@ export default {
}
return sprintfWorkItem(I18N_WORK_ITEM_ADD_BUTTON_LABEL, this.childrenTypeName);
},
+ confidentialityCheckboxLabel() {
+ return sprintfWorkItem(I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL, this.childrenTypeName);
+ },
+ confidentialityCheckboxTooltip() {
+ return sprintfWorkItem(
+ I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP,
+ this.childrenTypeName,
+ this.parentWorkItemType,
+ );
+ },
+ showConfidentialityTooltip() {
+ return this.isCreateForm && this.parentConfidential;
+ },
addOrCreateMethod() {
return this.isCreateForm ? this.createChild : this.addChild;
},
childWorkItemType() {
- return this.workItemTypes.find((type) => type.name === this.childrenTypeName)?.id;
+ return this.workItemTypes.find((type) => type.name === this.childrenTypeValue)?.id;
},
parentIterationId() {
return this.parentIteration?.id;
@@ -178,7 +213,10 @@ export default {
return this.parentMilestone?.id;
},
isSubmitButtonDisabled() {
- return this.isCreateForm ? this.search.length === 0 : this.workItemsToAdd.length === 0;
+ if (this.isCreateForm) {
+ return this.search.length === 0;
+ }
+ return this.workItemsToAdd.length === 0 || !this.areWorkItemsToAddValid;
},
isLoading() {
return this.$apollo.queries.availableWorkItems.loading;
@@ -186,12 +224,43 @@ export default {
addInputPlaceholder() {
return sprintfWorkItem(I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, this.childrenTypeName);
},
+ tokenSelectorContainerClass() {
+ return !this.areWorkItemsToAddValid ? 'gl-inset-border-1-red-500!' : '';
+ },
+ invalidWorkItemsToAdd() {
+ return this.parentConfidential
+ ? this.workItemsToAdd.filter((workItem) => !workItem.confidential)
+ : [];
+ },
+ areWorkItemsToAddValid() {
+ return this.invalidWorkItemsToAdd.length === 0;
+ },
+ showWorkItemsToAddInvalidMessage() {
+ return !this.isCreateForm && !this.areWorkItemsToAddValid;
+ },
+ workItemsToAddInvalidMessage() {
+ return sprintf(
+ s__(
+ 'WorkItem|%{invalidWorkItemsList} cannot be added: Cannot assign a non-confidential %{childWorkItemType} to a confidential parent %{parentWorkItemType}. Make the selected %{childWorkItemType} confidential and try again.',
+ ),
+ {
+ invalidWorkItemsList: this.invalidWorkItemsToAdd.map(({ title }) => title).join(', '),
+ childWorkItemType: this.childrenTypeName,
+ parentWorkItemType: this.parentWorkItemType,
+ },
+ );
+ },
},
created() {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
methods: {
getIdFromGraphQLId,
+ getConfidentialityTooltipTarget() {
+ // We want tooltip to be anchored to `input` within checkbox component
+ // but `$el.querySelector('input')` doesn't work. 🤷‍♂️
+ return this.$refs.confidentialityCheckbox?.$el;
+ },
unsetError() {
this.error = null;
},
@@ -299,30 +368,54 @@ export default {
autofocus
/>
</gl-form-group>
- <gl-token-selector
- v-else
- v-model="workItemsToAdd"
- :dropdown-items="availableWorkItems"
- :loading="isLoading"
- :placeholder="addInputPlaceholder"
- 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"
+ <gl-form-checkbox
+ v-if="isCreateForm"
+ ref="confidentialityCheckbox"
+ v-model="confidential"
+ name="isConfidential"
+ class="gl-md-mt-5 gl-mb-5 gl-md-mb-3!"
+ :disabled="parentConfidential"
+ >{{ confidentialityCheckboxLabel }}</gl-form-checkbox
+ >
+ <gl-tooltip
+ v-if="showConfidentialityTooltip"
+ :target="getConfidentialityTooltipTarget"
+ triggers="hover"
+ >{{ confidentialityCheckboxTooltip }}</gl-tooltip
>
- <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>
+ <div class="gl-mb-4">
+ <gl-token-selector
+ v-if="!isCreateForm"
+ v-model="workItemsToAdd"
+ :dropdown-items="availableWorkItems"
+ :loading="isLoading"
+ :placeholder="addInputPlaceholder"
+ menu-class="gl-dropdown-menu-wide dropdown-reduced-height gl-min-h-7!"
+ :container-class="tokenSelectorContainerClass"
+ 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>
+ <div
+ v-if="showWorkItemsToAddInvalidMessage"
+ class="gl-text-red-500"
+ data-testid="work-items-invalid"
+ >
+ {{ workItemsToAddInvalidMessage }}
+ </div>
+ </div>
<gl-button
category="primary"
variant="confirm"
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
index f06de2ca048..81e2bb76900 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
@@ -40,10 +40,20 @@ export default {
type: String,
required: true,
},
+ parentWorkItemType: {
+ type: String,
+ required: false,
+ default: '',
+ },
workItemId: {
type: String,
required: true,
},
+ confidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
children: {
type: Array,
required: false,
@@ -221,8 +231,10 @@ export default {
data-testid="add-tree-form"
:issuable-gid="workItemId"
:form-type="formType"
+ :parent-work-item-type="parentWorkItemType"
:children-type="childType"
:children-ids="childrenIds"
+ :parent-confidential="confidential"
@addWorkItemChild="$emit('addWorkItemChild', $event)"
@cancel="hideAddForm"
/>
@@ -233,11 +245,13 @@ export default {
:can-update="canUpdate"
:issuable-gid="workItemId"
:child-item="child"
+ :confidential="child.confidential"
:work-item-type="workItemType"
:has-indirect-children="hasIndirectChildren"
@mouseover="prefetchWorkItem(child)"
@mouseout="clearPrefetching"
@removeChild="$emit('removeChild', $event)"
+ @click="$emit('show-modal', $event, $event.childItem || child)"
/>
</div>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue
index 911cac4de88..71de6867680 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue
@@ -63,6 +63,7 @@ export default {
:child-item="child"
:work-item-type="workItemType"
@removeChild="updateWorkItem"
+ @click="$emit('click', Object.assign($event, { childItem: child }))"
/>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_note_signed_out.vue b/app/assets/javascripts/work_items/components/work_item_note_signed_out.vue
new file mode 100644
index 00000000000..3ef4a16bc57
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_note_signed_out.vue
@@ -0,0 +1,31 @@
+<script>
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { __, sprintf } from '~/locale';
+
+export default {
+ directives: {
+ SafeHtml,
+ },
+ inject: ['registerPath', 'signInPath'],
+ computed: {
+ signedOutText() {
+ return sprintf(
+ __(
+ 'Please %{startTagRegister}register%{endRegisterTag} or %{startTagSignIn}sign in%{endSignInTag} to reply',
+ ),
+ {
+ startTagRegister: `<a href="${this.registerPath}">`,
+ startTagSignIn: `<a href="${this.signInPath}">`,
+ endRegisterTag: '</a>',
+ endSignInTag: '</a>',
+ },
+ false,
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-safe-html="signedOutText" class="disabled-comment gl-text-center"></div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue
index 91e90589a93..a59767d8b70 100644
--- a/app/assets/javascripts/work_items/components/work_item_notes.vue
+++ b/app/assets/javascripts/work_items/components/work_item_notes.vue
@@ -2,8 +2,12 @@
import { GlSkeletonLoader } from '@gitlab/ui';
import { s__ } from '~/locale';
import SystemNote from '~/work_items/components/notes/system_note.vue';
+import ActivityFilter from '~/work_items/components/notes/activity_filter.vue';
import { i18n, DEFAULT_PAGE_SIZE_NOTES } from '~/work_items/constants';
+import { ASC, DESC } from '~/notes/constants';
import { getWorkItemNotesQuery } from '~/work_items/utils';
+import WorkItemNote from '~/work_items/components/notes/work_item_note.vue';
+import WorkItemCommentForm from './work_item_comment_form.vue';
export default {
i18n: {
@@ -15,8 +19,11 @@ export default {
height: 40,
},
components: {
- SystemNote,
GlSkeletonLoader,
+ ActivityFilter,
+ SystemNote,
+ WorkItemCommentForm,
+ WorkItemNote,
},
props: {
workItemId: {
@@ -31,22 +38,50 @@ export default {
type: String,
required: true,
},
+ workItemType: {
+ type: String,
+ required: true,
+ },
fetchByIid: {
type: Boolean,
required: false,
default: false,
},
},
+ data() {
+ return {
+ notesArray: [],
+ isLoadingMore: false,
+ perPage: DEFAULT_PAGE_SIZE_NOTES,
+ sortOrder: ASC,
+ changeNotesSortOrderAfterLoading: false,
+ };
+ },
computed: {
- areNotesLoading() {
- return this.$apollo.queries.workItemNotes.loading;
- },
- notes() {
- return this.workItemNotes?.nodes;
+ initialLoading() {
+ return this.$apollo.queries.workItemNotes.loading && !this.isLoadingMore;
},
pageInfo() {
return this.workItemNotes?.pageInfo;
},
+ avatarUrl() {
+ return window.gon.current_user_avatar_url;
+ },
+ hasNextPage() {
+ return this.pageInfo?.hasNextPage;
+ },
+ showInitialLoader() {
+ return this.initialLoading || this.changeNotesSortOrderAfterLoading;
+ },
+ showTimeline() {
+ return !this.changeNotesSortOrderAfterLoading;
+ },
+ showLoadingMoreSkeleton() {
+ return this.isLoadingMore && !this.changeNotesSortOrderAfterLoading;
+ },
+ disableActivityFilter() {
+ return this.initialLoading || this.isLoadingMore;
+ },
},
apollo: {
workItemNotes: {
@@ -59,6 +94,7 @@ export default {
variables() {
return {
...this.queryVariables,
+ after: this.after,
pageSize: DEFAULT_PAGE_SIZE_NOTES,
};
},
@@ -66,7 +102,11 @@ export default {
const workItemWidgets = this.fetchByIid
? data.workspace?.workItems?.nodes[0]?.widgets
: data.workItem?.widgets;
- return workItemWidgets.find((widget) => widget.type === 'NOTES').discussions || [];
+ const discussionNodes =
+ workItemWidgets.find((widget) => widget.type === 'NOTES')?.discussions || [];
+ this.notesArray = discussionNodes?.nodes || [];
+ this.updateSortingOrderIfApplicable();
+ return discussionNodes;
},
skip() {
return !this.queryVariables.id && !this.queryVariables.iid;
@@ -74,6 +114,58 @@ export default {
error() {
this.$emit('error', i18n.fetchError);
},
+ result() {
+ if (this.hasNextPage) {
+ this.fetchMoreNotes();
+ }
+ },
+ },
+ },
+ methods: {
+ isSystemNote(note) {
+ return note.notes.nodes[0].system;
+ },
+ updateSortingOrderIfApplicable() {
+ // when the sort order is DESC in local storage and there is only a single page, call
+ // changeSortOrder manually
+ if (
+ this.changeNotesSortOrderAfterLoading &&
+ this.perPage === DEFAULT_PAGE_SIZE_NOTES &&
+ !this.hasNextPage
+ ) {
+ this.changeNotesSortOrder(DESC);
+ }
+ },
+ updateInitialSortedOrder(direction) {
+ this.sortOrder = direction;
+ // when the direction is reverse , we need to load all since the sorting is on the frontend
+ if (direction === DESC) {
+ this.changeNotesSortOrderAfterLoading = true;
+ }
+ },
+ changeNotesSortOrder(direction) {
+ this.sortOrder = direction;
+ this.notesArray = [...this.notesArray].reverse();
+ this.changeNotesSortOrderAfterLoading = false;
+ },
+ async fetchMoreNotes() {
+ this.isLoadingMore = true;
+ // copied from discussions batch logic - every fetchMore call has a higher
+ // amount of page size than the previous one with the limit being 100
+ this.perPage = Math.min(Math.round(this.perPage * 1.5), 100);
+ await this.$apollo.queries.workItemNotes
+ .fetchMore({
+ variables: {
+ ...this.queryVariables,
+ pageSize: this.perPage,
+ after: this.pageInfo?.endCursor,
+ },
+ })
+ .catch((error) => this.$emit('error', error.message));
+ this.isLoadingMore = false;
+ if (this.changeNotesSortOrderAfterLoading && !this.hasNextPage) {
+ this.changeNotesSortOrder(this.sortOrder);
+ }
},
},
};
@@ -81,8 +173,18 @@ export default {
<template>
<div class="gl-border-t gl-mt-5">
- <label class="gl-mb-0">{{ $options.i18n.ACTIVITY_LABEL }}</label>
- <div v-if="areNotesLoading" class="gl-mt-5">
+ <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap">
+ <label class="gl-mb-0">{{ $options.i18n.ACTIVITY_LABEL }}</label>
+ <activity-filter
+ class="gl-min-h-5 gl-pb-3"
+ :loading="disableActivityFilter"
+ :sort-order="sortOrder"
+ :work-item-type="workItemType"
+ @changeSortOrder="changeNotesSortOrder"
+ @updateSavedSortOrder="updateInitialSortedOrder"
+ />
+ </div>
+ <div v-if="showInitialLoader" class="gl-mt-5">
<gl-skeleton-loader
v-for="index in $options.loader.repeat"
:key="index"
@@ -94,16 +196,40 @@ export default {
<rect width="500" x="45" y="15" height="10" rx="4" />
</gl-skeleton-loader>
</div>
- <div v-else class="issuable-discussion gl-mb-5 work-item-notes">
- <template v-if="notes && notes.length">
- <ul class="notes main-notes-list timeline">
- <system-note
- v-for="note in notes"
- :key="note.notes.nodes[0].id"
- :note="note.notes.nodes[0]"
+ <div v-else class="issuable-discussion gl-mb-5 gl-clearfix!">
+ <template v-if="showTimeline">
+ <ul class="notes main-notes-list timeline gl-clearfix!">
+ <template v-for="note in notesArray">
+ <system-note
+ v-if="isSystemNote(note)"
+ :key="note.notes.nodes[0].id"
+ :note="note.notes.nodes[0]"
+ />
+ <work-item-note v-else :key="note.notes.nodes[0].id" :note="note.notes.nodes[0]" />
+ </template>
+
+ <work-item-comment-form
+ :query-variables="queryVariables"
+ :full-path="fullPath"
+ :work-item-id="workItemId"
+ :fetch-by-iid="fetchByIid"
+ @error="$emit('error', $event)"
/>
</ul>
</template>
+
+ <template v-if="showLoadingMoreSkeleton">
+ <gl-skeleton-loader
+ v-for="index in $options.loader.repeat"
+ :key="index"
+ :width="$options.loader.width"
+ :height="$options.loader.height"
+ preserve-aspect-ratio="xMinYMax meet"
+ >
+ <circle cx="20" cy="20" r="16" />
+ <rect width="500" x="45" y="15" height="10" rx="4" />
+ </gl-skeleton-loader>
+ </template>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_type_icon.vue b/app/assets/javascripts/work_items/components/work_item_type_icon.vue
index 32678e29fa4..96a6493357c 100644
--- a/app/assets/javascripts/work_items/components/work_item_type_icon.vue
+++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue
@@ -33,11 +33,6 @@ export default {
},
computed: {
iconName() {
- // TODO: Remove this once https://gitlab.com/gitlab-org/gitlab-svgs/-/merge_requests/865
- // is merged and updated in GitLab repo.
- if (this.workItemIconName === 'issue-type-keyresult') {
- return 'issue-type-key-result';
- }
return (
this.workItemIconName || WORK_ITEMS_TYPE_MAP[this.workItemType]?.icon || 'issue-type-issue'
);
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index 3cd17f4d360..81f9bf04bc8 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -31,7 +31,12 @@ export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS';
export const WORK_ITEM_TYPE_ENUM_OBJECTIVE = 'OBJECTIVE';
export const WORK_ITEM_TYPE_ENUM_KEY_RESULT = 'KEY_RESULT';
+export const WORK_ITEM_TYPE_VALUE_INCIDENT = 'Incident';
export const WORK_ITEM_TYPE_VALUE_ISSUE = 'Issue';
+export const WORK_ITEM_TYPE_VALUE_TASK = 'Task';
+export const WORK_ITEM_TYPE_VALUE_TEST_CASE = 'Test case';
+export const WORK_ITEM_TYPE_VALUE_REQUIREMENTS = 'Requirements';
+export const WORK_ITEM_TYPE_VALUE_KEY_RESULT = 'Key Result';
export const WORK_ITEM_TYPE_VALUE_OBJECTIVE = 'Objective';
export const i18n = {
@@ -41,7 +46,7 @@ export const i18n = {
),
updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'),
confidentialTooltip: s__(
- 'WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this task.',
+ 'WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this %{workItemType}.',
),
};
@@ -73,12 +78,19 @@ export const I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL = s__('WorkItem|Add %{work
export const I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER = s__(
'WorkItem|Search existing %{workItemType}s',
);
+export const I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL = s__(
+ 'WorkItem|This %{workItemType} is confidential and should only be visible to team members with at least Reporter access',
+);
+export const I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP = s__(
+ 'WorkItem|A non-confidential %{workItemType} cannot be assigned to a confidential parent %{parentWorkItemType}.',
+);
-export const sprintfWorkItem = (msg, workItemTypeArg) => {
+export const sprintfWorkItem = (msg, workItemTypeArg, parentWorkItemType = '') => {
const workItemType = workItemTypeArg || s__('WorkItem|Work item');
return capitalizeFirstCharacter(
sprintf(msg, {
workItemType: workItemType.toLocaleLowerCase(),
+ parentWorkItemType: parentWorkItemType.toLocaleLowerCase(),
}),
);
};
@@ -96,30 +108,37 @@ export const WORK_ITEMS_TYPE_MAP = {
[WORK_ITEM_TYPE_ENUM_INCIDENT]: {
icon: `issue-type-incident`,
name: s__('WorkItem|Incident'),
+ value: WORK_ITEM_TYPE_VALUE_INCIDENT,
},
[WORK_ITEM_TYPE_ENUM_ISSUE]: {
icon: `issue-type-issue`,
name: s__('WorkItem|Issue'),
+ value: WORK_ITEM_TYPE_VALUE_ISSUE,
},
[WORK_ITEM_TYPE_ENUM_TASK]: {
icon: `issue-type-task`,
name: s__('WorkItem|Task'),
+ value: WORK_ITEM_TYPE_VALUE_TASK,
},
[WORK_ITEM_TYPE_ENUM_TEST_CASE]: {
icon: `issue-type-test-case`,
name: s__('WorkItem|Test case'),
+ value: WORK_ITEM_TYPE_VALUE_TEST_CASE,
},
[WORK_ITEM_TYPE_ENUM_REQUIREMENTS]: {
icon: `issue-type-requirements`,
name: s__('WorkItem|Requirements'),
+ value: WORK_ITEM_TYPE_VALUE_REQUIREMENTS,
},
[WORK_ITEM_TYPE_ENUM_OBJECTIVE]: {
icon: `issue-type-objective`,
name: s__('WorkItem|Objective'),
+ value: WORK_ITEM_TYPE_VALUE_OBJECTIVE,
},
[WORK_ITEM_TYPE_ENUM_KEY_RESULT]: {
- icon: `issue-type-issue`,
+ icon: `issue-type-keyresult`,
name: s__('WorkItem|Key Result'),
+ value: WORK_ITEM_TYPE_VALUE_KEY_RESULT,
},
};
@@ -141,7 +160,7 @@ export const WORK_ITEM_NAME_TO_ICON_MAP = {
Task: 'issue-type-task',
Objective: 'issue-type-objective',
// eslint-disable-next-line @gitlab/require-i18n-strings
- 'Key Result': 'issue-type-key-result',
+ 'Key Result': 'issue-type-keyresult',
};
export const FORM_TYPES = {
@@ -154,4 +173,6 @@ export const FORM_TYPES = {
};
export const DEFAULT_PAGE_SIZE_ASSIGNEES = 10;
-export const DEFAULT_PAGE_SIZE_NOTES = 100;
+export const DEFAULT_PAGE_SIZE_NOTES = 30;
+
+export const WORK_ITEM_NOTES_SORT_ORDER_KEY = 'sort_direction_work_item';
diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql
new file mode 100644
index 00000000000..6a7afd7bd5b
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql
@@ -0,0 +1,5 @@
+mutation createWorkItemNote($input: CreateNoteInput!) {
+ createNote(input: $input) {
+ errors
+ }
+}
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 3a23db3886a..fce10f6f2a6 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
@@ -11,6 +11,7 @@ query projectWorkItems(
id
title
state
+ confidential
}
}
}
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 6a81cc230b1..3ee263c149d 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -12,6 +12,7 @@ fragment WorkItem on WorkItem {
project {
id
fullPath
+ archived
}
workItemType {
id
diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
index 7fcf622cdb2..7d7bb9c7fc5 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
@@ -3,6 +3,7 @@ query workItemLinksQuery($id: WorkItemID!) {
id
workItemType {
id
+ name
}
title
userPermissions {
diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
index baefcdaea93..b7813ca4dc6 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
@@ -19,7 +19,6 @@ fragment WorkItemMetadataWidgets on WorkItemWidget {
}
... on WorkItemWidgetLabels {
type
- allowsScopedLabels
labels {
nodes {
...Label
diff --git a/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_note.fragment.graphql
index 62ced6bdfea..5215ea10918 100644
--- a/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_note.fragment.graphql
@@ -1,12 +1,16 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
-fragment Discussion on Note {
+fragment WorkItemNote on Note {
id
- body
bodyHtml
+ system
+ internal
systemNoteIconName
createdAt
author {
...User
}
+ userPermissions {
+ adminNote
+ }
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql
index 9439f22f955..9ea9cecc81a 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql
@@ -1,5 +1,5 @@
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-#import "~/work_items/graphql/discussion.fragment.graphql"
+#import "~/work_items/graphql/work_item_note.fragment.graphql"
query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) {
workItem(id: $id) {
@@ -8,7 +8,7 @@ query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) {
widgets {
... on WorkItemWidgetNotes {
type
- discussions(first: $pageSize, after: $after, filter: ONLY_ACTIVITY) {
+ discussions(first: $pageSize, after: $after, filter: ALL_NOTES) {
pageInfo {
...PageInfo
}
@@ -16,7 +16,7 @@ query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) {
id
notes {
nodes {
- ...Discussion
+ ...WorkItemNote
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql
index 3e0960f3f54..f401aa5595e 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql
@@ -1,5 +1,5 @@
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-#import "~/work_items/graphql/discussion.fragment.graphql"
+#import "~/work_items/graphql/work_item_note.fragment.graphql"
query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) {
workspace: project(fullPath: $fullPath) {
@@ -11,7 +11,7 @@ query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize
widgets {
... on WorkItemWidgetNotes {
type
- discussions(first: $pageSize, after: $after, filter: ONLY_ACTIVITY) {
+ discussions(first: $pageSize, after: $after, filter: ALL_NOTES) {
pageInfo {
...PageInfo
}
@@ -19,7 +19,7 @@ query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize
id
notes {
nodes {
- ...Discussion
+ ...WorkItemNote
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql
index 006ca29e01c..b4fb83b24c2 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql
@@ -1,6 +1,6 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "~/graphql_shared/fragments/user.fragment.graphql"
-#import "./work_item_metadata_widgets.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item_metadata_widgets.fragment.graphql"
query workItemTreeQuery($id: WorkItemID!) {
workItem(id: $id) {
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 cf3374e1737..d2a2d7927d3 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,7 +1,7 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/work_items/graphql/milestone.fragment.graphql"
-#import "./work_item_metadata_widgets.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item_metadata_widgets.fragment.graphql"
fragment WorkItemWidgets on WorkItemWidget {
... on WorkItemWidgetDescription {
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index a056fde6928..98b59449af7 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -10,6 +10,8 @@ export const initWorkItemsRoot = () => {
fullPath,
hasIssueWeightsFeature,
issuesListPath,
+ registerPath,
+ signInPath,
hasIterationsFeature,
hasOkrsFeature,
hasIssuableHealthStatusFeature,
@@ -26,6 +28,8 @@ export const initWorkItemsRoot = () => {
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasOkrsFeature: parseBoolean(hasOkrsFeature),
issuesListPath,
+ registerPath,
+ signInPath,
hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
},
diff --git a/app/assets/stylesheets/fonts.scss b/app/assets/stylesheets/fonts.scss
index a6ecca88bd4..a023b41083d 100644
--- a/app/assets/stylesheets/fonts.scss
+++ b/app/assets/stylesheets/fonts.scss
@@ -26,7 +26,36 @@ Usage:
src: font-url('jetbrains-mono/JetBrainsMono.woff2') format('woff2');
}
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-display: optional;
+ font-weight: bold;
+ src: font-url('jetbrains-mono/JetBrainsMono-Bold.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-display: optional;
+ font-weight: normal;
+ font-style: italic;
+ src: font-url('jetbrains-mono/JetBrainsMono-Italic.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-display: optional;
+ font-weight: bold;
+ font-style: italic;
+ src: font-url('jetbrains-mono/JetBrainsMono-BoldItalic.woff2') format('woff2');
+}
+
:root {
--default-mono-font: 'JetBrains Mono', 'Menlo';
--default-regular-font: 'GitLab Sans', -apple-system;
}
+
+// This isn't the best solution, but we needed a quick fix
+// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/107592/
+* {
+ font-variant-ligatures: none;
+}
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 07db6b3c147..e60353578b0 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -38,6 +38,7 @@
@import 'framework/sidebar';
@import 'framework/contextual_sidebar_header';
@import 'framework/contextual_sidebar';
+@import 'framework/super_sidebar';
@import 'framework/tables';
@import 'framework/notes';
@import 'framework/tabs';
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 14e756a5c21..0bc920b1f73 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -471,6 +471,10 @@ img.emoji {
.gl-font-size-28 { font-size: $gl-font-size-28; }
.gl-font-size-42 { font-size: $gl-font-size-42; }
+.gl-icon-button:hover {
+ background-color: $gray-100;
+}
+
.border-section {
@include gl-py-6;
@include gl-m-0;
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index 34c7ffa58fe..1e05441c731 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -138,13 +138,12 @@
}
@mixin top-level-item {
+ @include gl-h-7;
@include gl-px-4;
- @include gl-py-3;
@include gl-display-flex;
@include gl-align-items-center;
@include gl-rounded-base;
@include gl-w-auto;
- @include gl-line-height-normal;
transition: none;
margin: $sidebar-top-item-tb-margin $sidebar-top-item-lr-margin;
@@ -339,6 +338,7 @@
a {
@include top-level-item;
@include context-header;
+ @include gl-h-auto;
}
}
}
@@ -348,6 +348,7 @@
.context-header a {
@include context-header;
+ @include gl-h-auto;
}
> li {
@@ -457,9 +458,9 @@
// PANELS-SPECIFIC
//
+.icon-avatar,
.settings-avatar {
svg {
margin: auto;
}
}
-
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 0acda85f527..65d7eafb8b8 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -851,10 +851,6 @@
@include gl-focus($inset: true);
}
}
-
- .frequent-items-list-item-container a {
- display: flex;
- }
}
.section-header {
@@ -873,9 +869,10 @@
.frequent-items-item-title,
.frequent-items-item-namespace {
- max-width: 250px;
+ max-width: 220px;
text-overflow: ellipsis;
white-space: nowrap;
+ overflow: hidden;
}
.frequent-items-item-title {
@@ -895,6 +892,11 @@
.frequent-items-item-metadata-container {
float: none;
}
+
+ .frequent-items-item-title,
+ .frequent-items-item-namespace {
+ max-width: 250px;
+ }
}
}
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index ea741af918c..98083fbc72a 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -37,3 +37,14 @@
}
}
}
+
+.approvers-select {
+ .dropdown-menu {
+ @include gl-w-full;
+ @include gl-max-w-none;
+ }
+
+ .gl-dropdown-item-check-icon {
+ @include gl-display-none;
+ }
+}
diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss
new file mode 100644
index 00000000000..59a9df9ede0
--- /dev/null
+++ b/app/assets/stylesheets/framework/super_sidebar.scss
@@ -0,0 +1,22 @@
+.super-sidebar {
+ top: 0;
+ width: $contextual-sidebar-width;
+
+ .user-bar {
+ background-color: $t-gray-a-04;
+
+ .tanuki-logo {
+ @include gl-vertical-align-middle;
+ }
+ }
+
+ .context-switcher-toggle {
+ &[aria-expanded='true'] {
+ background-color: $t-gray-a-08;
+ }
+ }
+}
+
+.with-performance-bar .super-sidebar {
+ top: $performance-bar-height;
+}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index ec8ffaf8c53..539e92eeca4 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -392,7 +392,7 @@ $gl-font-size-large: 16px;
$gl-font-weight-normal: 400;
$gl-font-weight-bold: 600;
$gl-text-color: $gray-900;
-$gl-text-color-secondary: $gray-500;
+$gl-text-color-secondary: $gray-500 !default;
$gl-text-color-tertiary: $gray-400;
$gl-text-color-quaternary: #d6d6d6;
$gl-text-color-inverted: $white;
diff --git a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
index 2c5ea8347ae..e3cec187fab 100644
--- a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
+++ b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
@@ -339,19 +339,3 @@
display: inline;
}
}
-
-.gl-select2-html5-required-fix {
- .select2-container {
- + .select2 {
- @include gl-opacity-0;
- @include gl-border-0;
- @include gl-bg-none;
- @include gl-bg-transparent;
- display: block !important;
- width: 1px;
- height: 1px;
- z-index: -1;
- margin: -3px auto 0;
- }
- }
-}
diff --git a/app/assets/stylesheets/page_bundles/ci_status.scss b/app/assets/stylesheets/page_bundles/ci_status.scss
index 6b976106cc9..7adbf10b83a 100644
--- a/app/assets/stylesheets/page_bundles/ci_status.scss
+++ b/app/assets/stylesheets/page_bundles/ci_status.scss
@@ -1,10 +1,7 @@
@import 'mixins_and_variables_and_functions';
.ci-status {
- padding: 2px 7px 4px;
border: 1px solid var(--border-color, $border-color);
- white-space: nowrap;
- border-radius: 4px;
&:hover,
&:focus {
diff --git a/app/assets/stylesheets/page_bundles/editor.scss b/app/assets/stylesheets/page_bundles/editor.scss
index b7b698b2128..36da979ba1f 100644
--- a/app/assets/stylesheets/page_bundles/editor.scss
+++ b/app/assets/stylesheets/page_bundles/editor.scss
@@ -163,7 +163,6 @@
.gitignore-selector,
.gitlab-ci-yml-selector,
.dockerfile-selector,
- .template-type-selector,
.metrics-dashboard-selector {
display: inline-block;
vertical-align: top;
diff --git a/app/assets/stylesheets/page_bundles/import.scss b/app/assets/stylesheets/page_bundles/import.scss
index cd5e6d32e4e..a6c08e344f9 100644
--- a/app/assets/stylesheets/page_bundles/import.scss
+++ b/app/assets/stylesheets/page_bundles/import.scss
@@ -1,48 +1,10 @@
@import 'mixins_and_variables_and_functions';
-.import-jobs-from-col {
- width: 37%;
-}
-
-
-.import-jobs-to-col {
- width: 37%;
-}
-
-.import-jobs-status-col {
- width: 25%;
-}
-
-.import-jobs-cta-col {
- width: 1%;
-}
-
-.import-entities-target-select {
- &.disabled {
- .import-entities-target-select-separator {
- color: var(--gray-400, $gray-400);
- border-color: var(--gray-100, $gray-100);
- background-color: var(--gray-10, $gray-10);
- }
- }
-
- .import-entities-target-select-separator {
- border-color: var(--gray-200, $gray-200);
- background-color: var(--gray-10, $gray-10);
- }
-
- .gl-form-input {
- box-shadow: inset 0 0 0 1px var(--gray-200, $gray-200);
- }
-}
-
$import-bar-height: $gl-spacing-scale-11;
.import-table-bar {
- @include gl-sticky;
height: $import-bar-height;
top: $header-height;
- z-index: 3;
html.with-performance-bar & {
top: calc(#{$header-height} + #{$performance-bar-height});
@@ -50,16 +12,11 @@ $import-bar-height: $gl-spacing-scale-11;
}
.import-table {
- border-collapse: separate;
-
thead {
- @include gl-sticky;
- background-color: var(--gray-10, $gray-10);
top: calc(#{$header-height} + #{$import-bar-height});
- z-index: 3;
html.with-performance-bar & {
- top: calc(#{$header-height + $performance-bar-height} + #{$import-bar-height});
+ top: calc(#{$header-height} + #{$performance-bar-height} + #{$import-bar-height});
}
}
}
diff --git a/app/assets/stylesheets/page_bundles/members.scss b/app/assets/stylesheets/page_bundles/members.scss
index 8d2c0a8ca22..826921be8f0 100644
--- a/app/assets/stylesheets/page_bundles/members.scss
+++ b/app/assets/stylesheets/page_bundles/members.scss
@@ -76,6 +76,10 @@
width: px-to-rem(200px);
}
+ .col-activity {
+ width: px-to-rem(250px);
+ }
+
.col-actions {
width: px-to-rem(65px);
}
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 4950561bcb7..5c699dd81df 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -1002,12 +1002,12 @@ $tabs-holder-z-index: 250;
.md-header {
.gl-tab-nav-item {
- @include gl-text-gray-900;
+ color: var(--gl-text-color, $gl-text-color);
@include gl-pb-5;
&:hover {
@include gl-bg-none;
- @include gl-text-gray-900;
+ color: var(--gl-text-color, $gl-text-color);
&:not(.gl-tab-nav-item-active) {
@include gl-inset-border-b-2-gray-200;
@@ -1017,7 +1017,7 @@ $tabs-holder-z-index: 250;
.gl-tab-nav-item-active {
@include gl-font-weight-bold;
- @include gl-text-gray-900;
+ color: var(--gl-text-color, $gl-text-color);
@include gl-inset-border-b-2-theme-accent;
&:active,
@@ -1197,13 +1197,13 @@ $tabs-holder-z-index: 250;
}
.mr-section-container {
+ .media-body {
+ column-gap: 0;
+ }
+
.state-container-action-buttons {
@include media-breakpoint-up(md) {
flex-direction: row-reverse;
-
- .btn {
- margin-left: auto;
- }
}
}
}
diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
index b995724ec7c..f08d6e3ca95 100644
--- a/app/assets/stylesheets/page_bundles/oncall_schedules.scss
+++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
@@ -5,14 +5,14 @@
}
.timezone-dropdown {
- .dropdown-menu {
- @include gl-w-full;
- }
-
.gl-dropdown-item-text-primary {
@include gl-overflow-hidden;
@include gl-text-overflow-ellipsis;
}
+
+ .btn-block {
+ margin-bottom: 0;
+ }
}
.modal-footer {
@@ -20,7 +20,7 @@
}
.invalid-dropdown {
- .gl-dropdown-toggle {
+ .gl-button.gl-dropdown-toggle {
@include inset-border-1-red-500;
&:hover {
diff --git a/app/assets/stylesheets/page_bundles/todos.scss b/app/assets/stylesheets/page_bundles/todos.scss
index b35f5b38740..d4b0b4169d3 100644
--- a/app/assets/stylesheets/page_bundles/todos.scss
+++ b/app/assets/stylesheets/page_bundles/todos.scss
@@ -79,11 +79,11 @@
@include gl-py-0;
@include gl-px-1;
@include gl-m-0;
- @include gl-bg-gray-50;
@include gl-border-0;
@include gl-rounded-base;
@include gl-display-inline-flex;
- @include gl-text-body;
+ background: var(--gray-50, $gray-50);
+ color: var(--gl-text-color, $gl-text-color);
}
.gl-label-scoped {
diff --git a/app/assets/stylesheets/pages/ml_experiment_tracking.scss b/app/assets/stylesheets/pages/ml_experiment_tracking.scss
index c1582f2090b..3c025b5d23f 100644
--- a/app/assets/stylesheets/pages/ml_experiment_tracking.scss
+++ b/app/assets/stylesheets/pages/ml_experiment_tracking.scss
@@ -15,6 +15,20 @@
}
}
+table.ml-candidate-table {
+ table-layout: fixed;
+
+ tr td,
+ tr th {
+ padding: $gl-padding-8;
+
+ > * {
+ @include gl-display-block;
+ @include gl-text-truncate;
+ }
+ }
+}
+
table.candidate-details {
td {
padding: $gl-spacing-scale-3;
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 89be1c024db..d26e29c4047 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -63,18 +63,17 @@
}
}
+ @include media-breakpoint-down(md) {
+ .time-ago {
+ align-items: flex-end;
+ }
+ }
+
.duration,
.finished-at {
color: $gl-text-color-secondary;
margin: 0;
white-space: nowrap;
-
- svg {
- width: 12px;
- height: 12px;
- vertical-align: middle;
- margin-right: 4px;
- }
}
.build-link a {
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 15a32ea8ad3..ee91d955019 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -122,7 +122,7 @@
padding: 0;
background: transparent;
border: 0;
- line-height: 34px;
+ line-height: $gl-line-height-32;
margin: 0;
a {
@@ -495,7 +495,7 @@
.protected-branches-list,
.protected-tags-list {
- margin-bottom: 30px;
+ margin-bottom: 32px;
.settings-message {
margin: 0;
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index c7e55289b11..bb83a91bc57 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -536,10 +536,11 @@ a.gl-badge.badge-warning:active {
color: #89888d;
}
.gl-search-box-by-type-search-icon {
- margin: 0.5rem;
color: #89888d;
width: 1rem;
position: absolute;
+ left: 0.5rem;
+ top: calc(50% - 16px / 2);
}
.gl-search-box-by-type {
display: flex;
@@ -591,7 +592,7 @@ svg {
}
.toggle-sidebar-button .collapse-text,
.toggle-sidebar-button .icon-chevron-double-lg-left {
- color: #89888d;
+ color: #bfbfc3;
}
html {
overflow-y: scroll;
@@ -1136,15 +1137,13 @@ kbd {
}
.nav-sidebar li > a,
.nav-sidebar li > .fly-out-top-item-container {
+ height: 2rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
- padding-top: 0.5rem;
- padding-bottom: 0.5rem;
display: flex;
align-items: center;
border-radius: 0.25rem;
width: auto;
- line-height: 1rem;
margin: 1px 8px;
}
.nav-sidebar li.active > a {
@@ -1373,19 +1372,18 @@ kbd {
margin-top: 0.25rem;
}
.nav-sidebar-inner-scroll > div.context-header a {
+ height: 2rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
- padding-top: 0.5rem;
- padding-bottom: 0.5rem;
display: flex;
align-items: center;
border-radius: 0.25rem;
width: auto;
- line-height: 1rem;
margin: 1px 8px;
padding: 0.25rem;
margin-bottom: 0.25rem;
margin-top: 0.125rem;
+ height: auto;
}
.nav-sidebar-inner-scroll > div.context-header a .avatar-container {
font-weight: 400;
@@ -1398,6 +1396,7 @@ kbd {
padding: 0.25rem;
margin-bottom: 0.25rem;
margin-top: 0.125rem;
+ height: auto;
}
.sidebar-top-level-items .context-header a .avatar-container {
font-weight: 400;
@@ -1428,7 +1427,7 @@ kbd {
padding: 0 16px;
background-color: #24232a;
border: 0;
- color: #89888d;
+ color: #bfbfc3;
display: flex;
align-items: center;
background-color: #1f1e24;
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index f24b6fb9e81..9e1c6b065a0 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -536,10 +536,11 @@ a.gl-badge.badge-warning:active {
color: #737278;
}
.gl-search-box-by-type-search-icon {
- margin: 0.5rem;
color: #737278;
width: 1rem;
position: absolute;
+ left: 0.5rem;
+ top: calc(50% - 16px / 2);
}
.gl-search-box-by-type {
display: flex;
@@ -1136,15 +1137,13 @@ kbd {
}
.nav-sidebar li > a,
.nav-sidebar li > .fly-out-top-item-container {
+ height: 2rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
- padding-top: 0.5rem;
- padding-bottom: 0.5rem;
display: flex;
align-items: center;
border-radius: 0.25rem;
width: auto;
- line-height: 1rem;
margin: 1px 8px;
}
.nav-sidebar li.active > a {
@@ -1373,19 +1372,18 @@ kbd {
margin-top: 0.25rem;
}
.nav-sidebar-inner-scroll > div.context-header a {
+ height: 2rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
- padding-top: 0.5rem;
- padding-bottom: 0.5rem;
display: flex;
align-items: center;
border-radius: 0.25rem;
width: auto;
- line-height: 1rem;
margin: 1px 8px;
padding: 0.25rem;
margin-bottom: 0.25rem;
margin-top: 0.125rem;
+ height: auto;
}
.nav-sidebar-inner-scroll > div.context-header a .avatar-container {
font-weight: 400;
@@ -1398,6 +1396,7 @@ kbd {
padding: 0.25rem;
margin-bottom: 0.25rem;
margin-top: 0.125rem;
+ height: auto;
}
.sidebar-top-level-items .context-header a .avatar-container {
font-weight: 400;
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index 8db91fd9908..c471d6183d8 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -115,6 +115,8 @@ $data-viz-blue-950: #e9ebff;
$border-white-normal: $border-color;
+$gl-text-color-secondary: $gray-700;
+
$body-bg: $gray-10;
$input-bg: $white;
$input-focus-bg: $white;
@@ -130,7 +132,7 @@ $popover-color: $gray-950;
$popover-box-shadow: 0 2px 3px 1px $gray-700;
$popover-arrow-outer-color: $gray-800;
-$secondary: $gray-600;
+$secondary: $gray-700;
$yiq-text-dark: $gray-50;
$yiq-text-light: $gray-950;
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 714dd932147..7d98a780e55 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -236,13 +236,44 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709
}
}
-// TODO: Remove once https: //gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/3198 is merged
-.gl-sm-ml-5 {
- @include gl-media-breakpoint-up(sm) {
- @include gl-ml-5;
+.gl-mt-n5 {
+ margin-top: -$gl-spacing-scale-5;
+}
+
+// Utils below are very specific so cannot be part of GitLab UI
+.gl-md-mt-5 {
+ @include gl-media-breakpoint-up(md) {
+ margin-top: $gl-spacing-scale-5;
+ }
+}
+
+.gl-sm-mr-0\! {
+ @include gl-media-breakpoint-down(md) {
+ margin-right: 0 !important;
+ }
+}
+
+.gl-sm-mb-5 {
+ @include gl-media-breakpoint-down(md) {
+ margin-bottom: $gl-spacing-scale-5;
+ }
+}
+
+.gl-md-mb-3\! {
+ @include gl-media-breakpoint-up(md) {
+ margin-bottom: $gl-spacing-scale-3 !important;
}
}
+
+.gl-gap-2 {
+ gap: $gl-spacing-scale-2;
+}
+
+.gl-hover-bg-t-gray-a-08:hover {
+ background-color: $t-gray-a-08;
+}
+
/* End gitlab-ui#1709 */
/*
@@ -263,3 +294,7 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709
.gl-max-w-0 {
max-width: 0;
}
+
+.gl-isolate {
+ isolation: isolate;
+}
diff --git a/app/components/pajamas/badge_component.rb b/app/components/pajamas/badge_component.rb
index 4955bcd29ed..b60c12d20ef 100644
--- a/app/components/pajamas/badge_component.rb
+++ b/app/components/pajamas/badge_component.rb
@@ -23,7 +23,7 @@ module Pajamas
end
SIZE_OPTIONS = [:sm, :md, :lg].freeze
- VARIANT_OPTIONS = [:muted, :neutral, :info, :success, :warning, :danger].freeze
+ VARIANT_OPTIONS = [:muted, :neutral, :info, :success, :warning, :danger, :tier].freeze
private
diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb
index 80aca7e21ce..eec56682300 100644
--- a/app/controllers/abuse_reports_controller.rb
+++ b/app/controllers/abuse_reports_controller.rb
@@ -1,14 +1,25 @@
# frozen_string_literal: true
class AbuseReportsController < ApplicationController
- before_action :set_user, only: [:new]
+ before_action :set_user, only: [:new, :add_category]
feature_category :insider_threat
def new
- @abuse_report = AbuseReport.new
- @abuse_report.user_id = @user.id
- @ref_url = params.fetch(:ref_url, '')
+ @abuse_report = AbuseReport.new(
+ user_id: @user.id,
+ reported_from_url: params.fetch(:ref_url, '')
+ )
+ end
+
+ def add_category
+ @abuse_report = AbuseReport.new(
+ user_id: @user.id,
+ category: report_params[:category],
+ reported_from_url: report_params[:reported_from_url]
+ )
+
+ render :new
end
def create
@@ -30,7 +41,7 @@ class AbuseReportsController < ApplicationController
private
def report_params
- params.require(:abuse_report).permit(:message, :user_id)
+ params.require(:abuse_report).permit(:message, :user_id, :category, :reported_from_url)
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/admin/application_settings/appearances_controller.rb b/app/controllers/admin/application_settings/appearances_controller.rb
index 1a8447185a7..3cb31ff756f 100644
--- a/app/controllers/admin/application_settings/appearances_controller.rb
+++ b/app/controllers/admin/application_settings/appearances_controller.rb
@@ -68,7 +68,7 @@ class Admin::ApplicationSettings::AppearancesController < Admin::ApplicationCont
def allowed_appearance_params
%i[
title
- short_title
+ pwa_short_name
description
logo
logo_cache
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index b8c1bc266f7..ade58ca0970 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -137,8 +137,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
unless job_id.length <= PARAM_JOB_ID_MAX_SIZE
return render status: :bad_request, json: {
- message: _('Parameter "job_id" cannot exceed length of %{job_id_max_size}' %
- { job_id_max_size: PARAM_JOB_ID_MAX_SIZE })
+ message: format(_('Parameter "job_id" cannot exceed length of %{job_id_max_size}'), job_id_max_size: PARAM_JOB_ID_MAX_SIZE)
}
end
@@ -174,8 +173,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
unless job_id.length <= PARAM_JOB_ID_MAX_SIZE
return render status: :bad_request, json: {
- message: _('Parameter "job_id" cannot exceed length of %{job_id_max_size}' %
- { job_id_max_size: PARAM_JOB_ID_MAX_SIZE })
+ message: format(_('Parameter "job_id" cannot exceed length of %{job_id_max_size}'), job_id_max_size: PARAM_JOB_ID_MAX_SIZE)
}
end
diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb
index 093c5667a24..d641a26c9fb 100644
--- a/app/controllers/admin/broadcast_messages_controller.rb
+++ b/app/controllers/admin/broadcast_messages_controller.rb
@@ -99,7 +99,6 @@ module Admin
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/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index 37dde065e70..dab0f3e870a 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -2,6 +2,7 @@
class Admin::DashboardController < Admin::ApplicationController
include CountHelper
+ helper Admin::ComponentsHelper
COUNTED_ITEMS = [Project, User, Group].freeze
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index 8005babe19e..e3a33bafb62 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -41,7 +41,7 @@ class Admin::GroupsController < Admin::ApplicationController
@group = ::Groups::CreateService.new(current_user, group_params).execute
if @group.persisted?
- redirect_to [:admin, @group], notice: _('Group %{group_name} was successfully created.') % { group_name: @group.name }
+ redirect_to [:admin, @group], notice: format(_('Group %{group_name} was successfully created.'), group_name: @group.name)
else
render "new"
end
@@ -66,7 +66,7 @@ class Admin::GroupsController < Admin::ApplicationController
redirect_to admin_groups_path,
status: :found,
- alert: _('Group %{group_name} was scheduled for deletion.') % { group_name: @group.name }
+ alert: format(_('Group %{group_name} was scheduled for deletion.'), group_name: @group.name)
end
private
diff --git a/app/controllers/admin/impersonation_tokens_controller.rb b/app/controllers/admin/impersonation_tokens_controller.rb
index 9d884478e98..ddc555add5c 100644
--- a/app/controllers/admin/impersonation_tokens_controller.rb
+++ b/app/controllers/admin/impersonation_tokens_controller.rb
@@ -25,9 +25,9 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController
@impersonation_token = finder.find(params[:id])
if @impersonation_token.revoke!
- flash[:notice] = _("Revoked impersonation token %{token_name}!") % { token_name: @impersonation_token.name }
+ flash[:notice] = format(_("Revoked impersonation token %{token_name}!"), token_name: @impersonation_token.name)
else
- flash[:alert] = _("Could not revoke impersonation token %{token_name}.") % { token_name: @impersonation_token.name }
+ flash[:alert] = format(_("Could not revoke impersonation token %{token_name}."), token_name: @impersonation_token.name)
end
redirect_to admin_user_impersonation_tokens_path
diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb
index 9e841487508..5d37bd27302 100644
--- a/app/controllers/admin/projects_controller.rb
+++ b/app/controllers/admin/projects_controller.rb
@@ -43,7 +43,7 @@ class Admin::ProjectsController < Admin::ApplicationController
def destroy
::Projects::DestroyService.new(@project, current_user, {}).async_execute
- flash[:notice] = _("Project '%{project_name}' is in the process of being deleted.") % { project_name: @project.full_name }
+ flash[:notice] = format(_("Project '%{project_name}' is in the process of being deleted."), project_name: @project.full_name)
redirect_to admin_projects_path, status: :found
rescue Projects::DestroyService::DestroyError => e
diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb
index 180f4634136..984ae736697 100644
--- a/app/controllers/admin/spam_logs_controller.rb
+++ b/app/controllers/admin/spam_logs_controller.rb
@@ -16,7 +16,7 @@ class Admin::SpamLogsController < Admin::ApplicationController
spam_log.remove_user(deleted_by: current_user)
redirect_to admin_spam_logs_path,
status: :found,
- notice: _('User %{username} was successfully removed.') % { username: spam_log.user.username }
+ notice: format(_('User %{username} was successfully removed.'), username: spam_log.user.username)
else
spam_log.destroy
head :ok
diff --git a/app/controllers/admin/topics_controller.rb b/app/controllers/admin/topics_controller.rb
index e97ead12f71..345a778772d 100644
--- a/app/controllers/admin/topics_controller.rb
+++ b/app/controllers/admin/topics_controller.rb
@@ -23,7 +23,7 @@ class Admin::TopicsController < Admin::ApplicationController
@topic = Projects::Topic.new(topic_params)
if @topic.save
- redirect_to edit_admin_topic_path(@topic), notice: _('Topic %{topic_name} was successfully created.') % { topic_name: @topic.name }
+ redirect_to edit_admin_topic_path(@topic), notice: format(_('Topic %{topic_name} was successfully created.'), topic_name: @topic.name)
else
render "new"
end
@@ -42,7 +42,7 @@ class Admin::TopicsController < Admin::ApplicationController
redirect_to admin_topics_path,
status: :found,
- notice: _('Topic %{topic_name} was successfully removed.') % { topic_name: @topic.title_or_name }
+ notice: format(_('Topic %{topic_name} was successfully removed.'), topic_name: @topic.title_or_name)
end
def merge
@@ -53,7 +53,7 @@ class Admin::TopicsController < Admin::ApplicationController
return render status: :bad_request, json: { type: :alert, message: response.message } if response.error?
message = _('Topic %{source_topic} was successfully merged into topic %{target_topic}.')
- flash[:toast] = message % { source_topic: source_topic.name, target_topic: target_topic.name }
+ flash[:toast] = format(message, source_topic: source_topic.name, target_topic: target_topic.name)
redirect_to admin_topics_path, status: :found
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 5f6e3f0062f..4f379d8a75b 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -57,7 +57,7 @@ class Admin::UsersController < Admin::ApplicationController
log_impersonation_event
- flash[:alert] = _("You are now impersonating %{username}") % { username: user.username }
+ flash[:alert] = format(_("You are now impersonating %{username}"), username: user.username)
redirect_to root_path
else
@@ -81,7 +81,7 @@ class Admin::UsersController < Admin::ApplicationController
result = Users::RejectService.new(current_user).execute(user)
if result[:status] == :success
- redirect_back_or_admin_user(notice: _("You've rejected %{user}" % { user: user.name }))
+ redirect_back_or_admin_user(notice: format(_("You've rejected %{user}"), user: user.name))
else
redirect_back_or_admin_user(alert: result[:message])
end
@@ -105,7 +105,7 @@ class Admin::UsersController < Admin::ApplicationController
return redirect_back_or_admin_user(notice: _("Internal users cannot be deactivated")) if user.internal?
unless user.can_be_deactivated?
- return redirect_back_or_admin_user(notice: _("The user you are trying to deactivate has been active in the past %{minimum_inactive_days} days and cannot be deactivated") % { minimum_inactive_days: Gitlab::CurrentSettings.deactivate_dormant_users_period })
+ return redirect_back_or_admin_user(notice: format(_("The user you are trying to deactivate has been active in the past %{minimum_inactive_days} days and cannot be deactivated"), minimum_inactive_days: Gitlab::CurrentSettings.deactivate_dormant_users_period))
end
user.deactivate
@@ -124,8 +124,12 @@ class Admin::UsersController < Admin::ApplicationController
def unblock
if user.ldap_blocked?
- redirect_back_or_admin_user(alert: _("This user cannot be unlocked manually from GitLab"))
- elsif update_user { |user| user.activate }
+ return redirect_back_or_admin_user(alert: _("This user cannot be unlocked manually from GitLab"))
+ end
+
+ result = Users::UnblockService.new(current_user).execute(user)
+
+ if result.success?
redirect_back_or_admin_user(notice: _("Successfully unblocked"))
else
redirect_back_or_admin_user(alert: _("Error occurred. User was not unblocked"))
@@ -153,7 +157,7 @@ class Admin::UsersController < Admin::ApplicationController
end
def unlock
- if update_user { |user| user.unlock_access! }
+ if update_user(&:unlock_access!)
redirect_back_or_admin_user(notice: _("Successfully unlocked"))
else
redirect_back_or_admin_user(alert: _("Error occurred. User was not unlocked"))
@@ -161,7 +165,7 @@ class Admin::UsersController < Admin::ApplicationController
end
def confirm
- if update_user { |user| user.force_confirm }
+ if update_user(&:force_confirm)
redirect_back_or_admin_user(notice: _("Successfully confirmed"))
else
redirect_back_or_admin_user(alert: _("Error occurred. User was not confirmed"))
@@ -358,6 +362,7 @@ class Admin::UsersController < Admin::ApplicationController
:username,
:website_url,
:note,
+ :private_profile,
credit_card_validation_attributes: [:credit_card_validated_at]
]
end
@@ -377,7 +382,7 @@ class Admin::UsersController < Admin::ApplicationController
end
def log_impersonation_event
- Gitlab::AppLogger.info(_("User %{current_user_username} has started impersonating %{username}") % { current_user_username: current_user.username, username: user.username })
+ Gitlab::AppLogger.info(format(_("User %{current_user_username} has started impersonating %{username}"), current_user_username: current_user.username, username: user.username))
end
def can_impersonate_user
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index e64d3110c3a..36aae42e21f 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -24,6 +24,7 @@ class ApplicationController < ActionController::Base
include ::Gitlab::EndpointAttributes
include FlocOptOut
include CheckRateLimit
+ extend ContentSecurityPolicyPatch
before_action :limit_session_time, if: -> { !current_user }
before_action :authenticate_user!, except: [:route_not_found]
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index 45585ab84b4..668b2ebaf9e 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -9,7 +9,7 @@ class AutocompleteController < ApplicationController
feature_category :users, [:users, :user]
feature_category :projects, [:projects]
feature_category :team_planning, [:award_emojis]
- feature_category :code_review, [:merge_request_target_branches]
+ feature_category :code_review_workflow, [:merge_request_target_branches]
feature_category :continuous_delivery, [:deploy_keys_with_owners]
urgency :low, [:merge_request_target_branches, :deploy_keys_with_owners, :users]
diff --git a/app/controllers/concerns/access_tokens_actions.rb b/app/controllers/concerns/access_tokens_actions.rb
index fdb08c6572f..6a84c436aae 100644
--- a/app/controllers/concerns/access_tokens_actions.rb
+++ b/app/controllers/concerns/access_tokens_actions.rb
@@ -43,9 +43,9 @@ module AccessTokensActions
revoked_response = ResourceAccessTokens::RevokeService.new(current_user, resource, @resource_access_token).execute
if revoked_response.success?
- flash[:notice] = _("Revoked access token %{access_token_name}!") % { access_token_name: @resource_access_token.name }
+ flash[:notice] = format(_("Revoked access token %{access_token_name}!"), access_token_name: @resource_access_token.name)
else
- flash[:alert] = _("Could not revoke access token %{access_token_name}.") % { access_token_name: @resource_access_token.name }
+ flash[:alert] = format(_("Could not revoke access token %{access_token_name}."), access_token_name: @resource_access_token.name)
end
redirect_to resource_access_tokens_path
diff --git a/app/controllers/concerns/check_rate_limit.rb b/app/controllers/concerns/check_rate_limit.rb
index 0eaf74fd3a9..fc3be3ad009 100644
--- a/app/controllers/concerns/check_rate_limit.rb
+++ b/app/controllers/concerns/check_rate_limit.rb
@@ -8,10 +8,7 @@
# See lib/api/helpers/rate_limiter.rb for API version
module CheckRateLimit
def check_rate_limit!(key, scope:, redirect_back: false, **options)
- return if bypass_header_set?
- return unless rate_limiter.throttled?(key, scope: scope, **options)
-
- rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user)
+ return unless Gitlab::ApplicationRateLimiter.throttled_request?(request, current_user, key, scope: scope, **options)
return yield if block_given?
@@ -23,14 +20,4 @@ module CheckRateLimit
render plain: message, status: :too_many_requests
end
end
-
- private
-
- def rate_limiter
- ::Gitlab::ApplicationRateLimiter
- end
-
- def bypass_header_set?
- ::Gitlab::Throttle.bypass_header.present? && request.get_header(Gitlab::Throttle.bypass_header) == '1'
- end
end
diff --git a/app/controllers/concerns/confirm_email_warning.rb b/app/controllers/concerns/confirm_email_warning.rb
index 32e1a46e580..ec5140bf223 100644
--- a/app/controllers/concerns/confirm_email_warning.rb
+++ b/app/controllers/concerns/confirm_email_warning.rb
@@ -19,10 +19,17 @@ module ConfirmEmailWarning
email = current_user.unconfirmed_email || current_user.email
- flash.now[:warning] = _("Please check your email (%{email}) to verify that you own this address and unlock the power of CI/CD. Didn't receive it? %{resend_link}. Wrong email address? %{update_link}.").html_safe % {
+ flash.now[:warning] = format(
+ confirm_warning_message,
email: email,
resend_link: view_context.link_to(_('Resend it'), user_confirmation_path(user: { email: email }), method: :post),
update_link: view_context.link_to(_('Update it'), profile_path)
- }
+ ).html_safe
+ end
+
+ private
+
+ def confirm_warning_message
+ _("Please check your email (%{email}) to verify that you own this address and unlock the power of CI/CD. Didn't receive it? %{resend_link}. Wrong email address? %{update_link}.")
end
end
diff --git a/app/controllers/concerns/content_security_policy_patch.rb b/app/controllers/concerns/content_security_policy_patch.rb
new file mode 100644
index 00000000000..a4dc232ee42
--- /dev/null
+++ b/app/controllers/concerns/content_security_policy_patch.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+##
+# `content_security_policy_with_context` makes the caller's context available to the invoked block,
+# as this is currently not accessible from `content_security_policy`
+#
+# This patch is available in content_security_policy starting with Rails 7.2.
+# Refs: https://github.com/rails/rails/pull/45115.
+module ContentSecurityPolicyPatch
+ def content_security_policy_with_context(enabled = true, **options, &block)
+ if Rails.gem_version >= Gem::Version.new("7.2")
+ ActiveSupport::Deprecation.warn(
+ "content_security_policy_with_context should only be used with Rails < 7.2.
+ Use content_security_policy instead.")
+ end
+
+ before_action(options) do
+ if block
+ policy = current_content_security_policy
+ instance_exec(policy, &block)
+ request.content_security_policy = policy
+ end
+
+ request.content_security_policy = nil unless enabled
+ end
+ end
+end
diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb
index c8de041d5bd..cdef1a45a27 100644
--- a/app/controllers/concerns/enforces_two_factor_authentication.rb
+++ b/app/controllers/concerns/enforces_two_factor_authentication.rb
@@ -25,8 +25,9 @@ module EnforcesTwoFactorAuthentication
case self
when GraphqlController
render_error(
- _("Authentication error: enable 2FA in your profile settings to continue using GitLab: %{mfa_help_page}") %
- { mfa_help_page: mfa_help_page_url },
+ format(
+ _("Authentication error: enable 2FA in your profile settings to continue using GitLab: %{mfa_help_page}"),
+ mfa_help_page: mfa_help_page_url),
status: :unauthorized
)
else
diff --git a/app/controllers/concerns/gitlab_recaptcha.rb b/app/controllers/concerns/gitlab_recaptcha.rb
index cedadba5fc7..7b2382eee4c 100644
--- a/app/controllers/concerns/gitlab_recaptcha.rb
+++ b/app/controllers/concerns/gitlab_recaptcha.rb
@@ -2,7 +2,7 @@
module GitlabRecaptcha
extend ActiveSupport::Concern
- include Recaptcha::Verify
+ include Recaptcha::Adapters::ControllerMethods
include RecaptchaHelper
def load_recaptcha
diff --git a/app/controllers/concerns/integrations/actions.rb b/app/controllers/concerns/integrations/actions.rb
index e0a12555e11..7bebafae0fd 100644
--- a/app/controllers/concerns/integrations/actions.rb
+++ b/app/controllers/concerns/integrations/actions.rb
@@ -57,9 +57,9 @@ module Integrations::Actions
def success_message
if integration.active?
- s_('Integrations|%{integration} settings saved and active.') % { integration: integration.title }
+ format(s_('Integrations|%{integration} settings saved and active.'), integration: integration.title)
else
- s_('Integrations|%{integration} settings saved, but not active.') % { integration: integration.title }
+ format(s_('Integrations|%{integration} settings saved, but not active.'), integration: integration.title)
end
end
diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb
index 74d998503b7..4d181ded071 100644
--- a/app/controllers/concerns/integrations/params.rb
+++ b/app/controllers/concerns/integrations/params.rb
@@ -5,6 +5,9 @@ module Integrations
extend ActiveSupport::Concern
ALLOWED_PARAMS_CE = [
+ :app_store_issuer_id,
+ :app_store_key_id,
+ :app_store_private_key,
:active,
:alert_events,
:api_key,
@@ -38,6 +41,7 @@ module Integrations
:external_wiki_url,
:google_iap_service_account_json,
:google_iap_audience_client_id,
+ :incident_events,
:inherit_from_id,
# We're using `issues_events` and `merge_requests_events`
# in the view so we still need to explicitly state them
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index 7b0d8cf8dcb..5060ce69d9c 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -3,6 +3,7 @@
module IssuableCollections
extend ActiveSupport::Concern
include PaginatedCollection
+ include SearchRateLimitable
include SortingHelper
include SortingPreference
include Gitlab::Utils::StrongMemoize
diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb
index 7beb86b51fd..b8249345a54 100644
--- a/app/controllers/concerns/issuable_collections_action.rb
+++ b/app/controllers/concerns/issuable_collections_action.rb
@@ -5,6 +5,12 @@ module IssuableCollectionsAction
include IssuableCollections
include IssuesCalendar
+ included do
+ before_action :check_search_rate_limit!, only: [:issues, :merge_requests], if: -> {
+ params[:search].present? && Feature.enabled?(:rate_limit_issuable_searches)
+ }
+ end
+
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def issues
show_alert_if_search_is_disabled
diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb
index 1653b40bad5..1c4521e2353 100644
--- a/app/controllers/concerns/lfs_request.rb
+++ b/app/controllers/concerns/lfs_request.rb
@@ -80,7 +80,7 @@ module LfsRequest
def lfs_download_access?
ci? || lfs_deploy_token? || user_can_download_code? || build_can_download_code? || deploy_token_can_download_code?
end
- strong_memoize_attr :lfs_download_access?, :lfs_download_access
+ strong_memoize_attr :lfs_download_access?
def deploy_token_can_download_code?
deploy_token.present? &&
@@ -92,10 +92,12 @@ module LfsRequest
return false unless has_authentication_ability?(:push_code)
return false if limit_exceeded?
- lfs_deploy_token? || can?(user, :push_code,
-project) || can?(deploy_token, :push_code, project) || any_branch_allows_collaboration?
+ lfs_deploy_token? ||
+ can?(user, :push_code, project) ||
+ can?(deploy_token, :push_code, project) ||
+ any_branch_allows_collaboration?
end
- strong_memoize_attr :lfs_upload_access?, :lfs_upload_access
+ strong_memoize_attr :lfs_upload_access?
def any_branch_allows_collaboration?
project.merge_requests_allowing_push_to_user(user).any?
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index 28d0af7a118..7c6e449b509 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -11,7 +11,7 @@ module MembershipActions
.new(current_user, update_params)
.execute(member)
- member = result[:member]
+ member = result[:members].first
member_data = if member.expires?
{
@@ -66,8 +66,7 @@ module MembershipActions
notice: _('Your request for access has been queued for review.')
else
redirect_to polymorphic_path(membershipable),
- alert: _("Your request for access could not be processed: %{error_message}") %
- { error_message: access_requester.errors.full_messages.to_sentence }
+ alert: format(_("Your request for access could not be processed: %{error_message}"), error_message: access_requester.errors.full_messages.to_sentence)
end
end
@@ -87,9 +86,9 @@ module MembershipActions
notice =
if member.request?
- _("Your access request to the %{source_type} has been withdrawn.") % { source_type: source_type }
+ format(_("Your access request to the %{source_type} has been withdrawn."), source_type: source_type)
else
- _("You left the \"%{membershipable_human_name}\" %{source_type}.") % { membershipable_human_name: membershipable.human_name, source_type: source_type }
+ format(_("You left the \"%{membershipable_human_name}\" %{source_type}."), membershipable_human_name: membershipable.human_name, source_type: source_type)
end
respond_to do |format|
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index a41e2d840ac..512dbf0de5d 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -100,7 +100,7 @@ module NotesActions
def gather_all_notes
now = Time.current
- notes = merge_resource_events(notes_finder.execute.inc_relations_for_view)
+ notes = merge_resource_events(notes_finder.execute.inc_relations_for_view(noteable))
[notes, { last_fetched_at: (now.to_i * MICROSECOND) + now.usec }]
end
diff --git a/app/controllers/concerns/observability/content_security_policy.rb b/app/controllers/concerns/observability/content_security_policy.rb
index eccd1e1e3ef..3865e3b606d 100644
--- a/app/controllers/concerns/observability/content_security_policy.rb
+++ b/app/controllers/concerns/observability/content_security_policy.rb
@@ -5,8 +5,14 @@ module Observability
extend ActiveSupport::Concern
included do
- content_security_policy do |p|
- next if p.directives.blank? || Gitlab::Observability.observability_url.blank?
+ content_security_policy_with_context do |p|
+ current_group = if defined?(group)
+ group
+ else
+ defined?(project) ? project&.group : nil
+ end
+
+ next if p.directives.blank? || !Gitlab::Observability.observability_enabled?(current_user, current_group)
default_frame_src = p.directives['frame-src'] || p.directives['default-src']
diff --git a/app/controllers/concerns/redirects_for_missing_path_on_tree.rb b/app/controllers/concerns/redirects_for_missing_path_on_tree.rb
index 085afbf3975..92574dfade9 100644
--- a/app/controllers/concerns/redirects_for_missing_path_on_tree.rb
+++ b/app/controllers/concerns/redirects_for_missing_path_on_tree.rb
@@ -8,7 +8,7 @@ module RedirectsForMissingPathOnTree
private
def missing_path_on_ref(path, ref)
- _('"%{path}" did not exist on "%{ref}"') % { path: truncate_path(path), ref: ref }
+ format(_('"%{path}" did not exist on "%{ref}"'), path: truncate_path(path), ref: ref)
end
def truncate_path(path)
diff --git a/app/controllers/concerns/renders_blob.rb b/app/controllers/concerns/renders_blob.rb
index a15bf27a22f..c3ccd9edd87 100644
--- a/app/controllers/concerns/renders_blob.rb
+++ b/app/controllers/concerns/renders_blob.rb
@@ -35,6 +35,6 @@ module RendersBlob
def conditionally_expand_blobs(blobs)
return unless params[:expanded] == 'true'
- blobs.each { |blob| blob.expand! }
+ blobs.each(&:expand!)
end
end
diff --git a/app/controllers/concerns/spammable_actions/akismet_mark_as_spam_action.rb b/app/controllers/concerns/spammable_actions/akismet_mark_as_spam_action.rb
index 044519004b2..6ba079ee658 100644
--- a/app/controllers/concerns/spammable_actions/akismet_mark_as_spam_action.rb
+++ b/app/controllers/concerns/spammable_actions/akismet_mark_as_spam_action.rb
@@ -9,7 +9,7 @@ module SpammableActions::AkismetMarkAsSpamAction
def mark_as_spam
if Spam::AkismetMarkAsSpamService.new(target: spammable).execute
- redirect_to spammable_path, notice: _("%{spammable_titlecase} was submitted to Akismet successfully.") % { spammable_titlecase: spammable.spammable_entity_type.titlecase }
+ redirect_to spammable_path, notice: format(_("%{spammable_titlecase} was submitted to Akismet successfully."), spammable_titlecase: spammable.spammable_entity_type.titlecase)
else
redirect_to spammable_path, alert: _('Error with Akismet. Please check the logs for more info.')
end
diff --git a/app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb b/app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb
index 707c1e6c84f..23db6a4b368 100644
--- a/app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb
+++ b/app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb
@@ -24,7 +24,7 @@ module SpammableActions::CaptchaCheck::HtmlFormatActionsSupport
# Convert spam/CAPTCHA values from form field params to headers, because all spam-related services
# expect these values to be passed as headers.
#
- # The 'g-recaptcha-response' field name comes from `Recaptcha::ClientHelper#recaptcha_tags` in the
+ # The 'g-recaptcha-response' field name comes from `Recaptcha::Adapters::ViewMethods#recaptcha_tags` in the
# recaptcha gem. This is a field which is automatically included by calling the
# `#recaptcha_tags` method within a HAML template's form.
def convert_html_spam_params_to_headers
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
index 0ba13896631..308da018a42 100644
--- a/app/controllers/concerns/uploads_actions.rb
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -5,7 +5,7 @@ module UploadsActions
include Gitlab::Utils::StrongMemoize
include SendFileUpload
- UPLOAD_MOUNTS = %w[avatar attachment file logo header_logo favicon].freeze
+ UPLOAD_MOUNTS = %w[avatar attachment file logo pwa_icon header_logo favicon].freeze
included do
prepend_before_action :set_request_format_from_path_extension
diff --git a/app/controllers/concerns/verifies_with_email.rb b/app/controllers/concerns/verifies_with_email.rb
index 3cada24a81a..82388090350 100644
--- a/app/controllers/concerns/verifies_with_email.rb
+++ b/app/controllers/concerns/verifies_with_email.rb
@@ -105,8 +105,10 @@ module VerifiesWithEmail
end
def render_sign_in_rate_limited
- message = s_('IdentityVerification|Maximum login attempts exceeded. '\
- 'Wait %{interval} and try again.') % { interval: user_sign_in_interval }
+ message = format(
+ s_('IdentityVerification|Maximum login attempts exceeded. Wait %{interval} and try again.'),
+ interval: user_sign_in_interval
+ )
redirect_to new_user_session_path, alert: message
end
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 82e5bb6cd7c..3d9184979d7 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -16,7 +16,7 @@ class DashboardController < Dashboard::ApplicationController
feature_category :users, [:activity]
feature_category :team_planning, [:issues, :issues_calendar]
- feature_category :code_review, [:merge_requests]
+ feature_category :code_review_workflow, [:merge_requests]
urgency :low, [:merge_requests, :activity]
urgency :low, [:issues, :issues_calendar]
diff --git a/app/controllers/groups/autocomplete_sources_controller.rb b/app/controllers/groups/autocomplete_sources_controller.rb
index 171494e66bd..6936733c4f7 100644
--- a/app/controllers/groups/autocomplete_sources_controller.rb
+++ b/app/controllers/groups/autocomplete_sources_controller.rb
@@ -3,7 +3,7 @@
class Groups::AutocompleteSourcesController < Groups::ApplicationController
feature_category :subgroups, [:members]
feature_category :team_planning, [:issues, :labels, :milestones, :commands]
- feature_category :code_review, [:merge_requests]
+ feature_category :code_review_workflow, [:merge_requests]
urgency :low, [:issues, :labels, :milestones, :commands, :merge_requests, :members]
diff --git a/app/controllers/groups/imports_controller.rb b/app/controllers/groups/imports_controller.rb
index a35237a706d..569e26b3de9 100644
--- a/app/controllers/groups/imports_controller.rb
+++ b/app/controllers/groups/imports_controller.rb
@@ -14,7 +14,8 @@ class Groups::ImportsController < Groups::ApplicationController
redirect_to group_path(@group), notice: s_('GroupImport|The group was successfully imported.')
end
elsif @group.import_state.failed?
- redirect_to new_group_path(@group), alert: s_('GroupImport|Failed to import group.')
+ redirect_to new_group_path(@group),
+ alert: format(s_('GroupImport|Failed to import group: %{error}'), error: @group.import_state.last_error)
else
flash.now[:notice] = continue_params[:notice_now]
end
diff --git a/app/controllers/groups/observability_controller.rb b/app/controllers/groups/observability_controller.rb
index 3baa5e830ff..726af00a10e 100644
--- a/app/controllers/groups/observability_controller.rb
+++ b/app/controllers/groups/observability_controller.rb
@@ -19,6 +19,10 @@ module Groups
render_observability
end
+ def datasources
+ render_observability
+ end
+
private
def render_observability
@@ -26,9 +30,7 @@ module Groups
end
def check_observability_allowed
- return render_404 unless Gitlab::Observability.observability_url.present?
-
- render_404 unless can?(current_user, :read_observability, @group)
+ render_404 unless Gitlab::Observability.observability_enabled?(current_user, group)
end
end
end
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index 1dfa8cdf133..78e3ffa4af9 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -23,7 +23,7 @@ module Groups
if update_group_service.execute
flash[:notice] = s_('GroupSettings|Pipeline settings was updated for the group')
else
- flash[:alert] = s_("GroupSettings|There was a problem updating the pipeline settings: %{error_messages}." % { error_messages: group.errors.full_messages })
+ flash[:alert] = format(s_("GroupSettings|There was a problem updating the pipeline settings: %{error_messages}."), error_messages: group.errors.full_messages)
end
redirect_to group_settings_ci_cd_path
@@ -33,7 +33,7 @@ module Groups
if auto_devops_service.execute
flash[:notice] = s_('GroupSettings|Auto DevOps pipeline was updated for the group')
else
- flash[:alert] = s_("GroupSettings|There was a problem updating Auto DevOps pipeline: %{error_messages}." % { error_messages: group.errors.full_messages })
+ flash[:alert] = format(s_("GroupSettings|There was a problem updating Auto DevOps pipeline: %{error_messages}."), error_messages: group.errors.full_messages)
end
redirect_to group_settings_ci_cd_path
diff --git a/app/controllers/groups/usage_quotas_controller.rb b/app/controllers/groups/usage_quotas_controller.rb
index 29878f0001d..b660eb3af99 100644
--- a/app/controllers/groups/usage_quotas_controller.rb
+++ b/app/controllers/groups/usage_quotas_controller.rb
@@ -16,8 +16,7 @@ module Groups
private
def verify_usage_quotas_enabled!
- render_404 unless Feature.enabled?(:usage_quotas_for_all_editions, group)
- render_404 if group.has_parent?
+ render_404 unless group.usage_quotas_enabled?
end
# To be overriden in ee/app/controllers/ee/groups/usage_quotas_controller.rb
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 0a487bb2508..e440b60ad1f 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -8,7 +8,7 @@ class GroupsController < Groups::ApplicationController
include RecordUserLastActivity
include SendFileUpload
include FiltersEvents
- include Recaptcha::Verify
+ include Recaptcha::Adapters::ControllerMethods
extend ::Gitlab::Utils::Override
respond_to :html
@@ -55,7 +55,7 @@ class GroupsController < Groups::ApplicationController
]
feature_category :team_planning, [:issues, :issues_calendar, :preview_markdown]
- feature_category :code_review, [:merge_requests, :unfoldered_environment_names]
+ feature_category :code_review_workflow, [:merge_requests, :unfoldered_environment_names]
feature_category :projects, [:projects]
feature_category :importers, [:export, :download_export]
urgency :low, [:export, :download_export]
@@ -256,7 +256,7 @@ class GroupsController < Groups::ApplicationController
def determine_layout
if [:new, :create].include?(action_name.to_sym)
- 'application'
+ 'dashboard'
elsif [:edit, :update, :projects].include?(action_name.to_sym)
'group_settings'
else
diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb
index 8a8c41e65b9..bedeae3cf54 100644
--- a/app/controllers/ide_controller.rb
+++ b/app/controllers/ide_controller.rb
@@ -10,7 +10,6 @@ class IdeController < ApplicationController
before_action do
push_frontend_feature_flag(:build_service_proxy)
- push_frontend_feature_flag(:schema_linting)
push_frontend_feature_flag(:reject_unsigned_commits_by_gitlab)
define_index_vars
end
diff --git a/app/controllers/import/available_namespaces_controller.rb b/app/controllers/import/available_namespaces_controller.rb
deleted file mode 100644
index c16c40cefea..00000000000
--- a/app/controllers/import/available_namespaces_controller.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-# frozen_string_literal: true
-
-class Import::AvailableNamespacesController < ApplicationController
- feature_category :importers
- urgency :low
-
- def index
- render json: NamespaceSerializer.new.represent(current_user.manageable_groups_with_routes(include_groups_with_developer_maintainer_access: true))
- end
-end
diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb
index 1d05cee02d4..8a0f4a36781 100644
--- a/app/controllers/import/bitbucket_controller.rb
+++ b/app/controllers/import/bitbucket_controller.rb
@@ -78,12 +78,12 @@ class Import::BitbucketController < Import::BaseController
override :importable_repos
def importable_repos
- bitbucket_repos.filter { |repo| repo.valid? }
+ bitbucket_repos.filter(&:valid?)
end
override :incompatible_repos
def incompatible_repos
- bitbucket_repos.reject { |repo| repo.valid? }
+ bitbucket_repos.reject(&:valid?)
end
def provider_url
diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb
index 12147196749..40664922d3d 100644
--- a/app/controllers/import/bitbucket_server_controller.rb
+++ b/app/controllers/import/bitbucket_server_controller.rb
@@ -63,12 +63,12 @@ class Import::BitbucketServerController < Import::BaseController
override :importable_repos
def importable_repos
- bitbucket_repos.filter { |repo| repo.valid? }
+ bitbucket_repos.filter(&:valid?)
end
override :incompatible_repos
def incompatible_repos
- bitbucket_repos.reject { |repo| repo.valid? }
+ bitbucket_repos.reject(&:valid?)
end
override :provider_name
diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb
index 9a7118ce498..e9705c45116 100644
--- a/app/controllers/import/bulk_imports_controller.rb
+++ b/app/controllers/import/bulk_imports_controller.rb
@@ -3,8 +3,11 @@
class Import::BulkImportsController < ApplicationController
include ActionView::Helpers::SanitizeHelper
- before_action :ensure_group_import_enabled
+ before_action :ensure_bulk_import_enabled
before_action :verify_blocked_uri, only: :status
+ before_action only: :status do
+ push_frontend_feature_flag(:bulk_import_projects)
+ end
feature_category :importers
urgency :low
@@ -17,6 +20,9 @@ class Import::BulkImportsController < ApplicationController
session[access_token_key] = configure_params[access_token_key]&.strip
session[url_key] = configure_params[url_key]
+ verify_blocked_uri && performed? && return
+ validate_configure_params!
+
redirect_to status_import_bulk_imports_url(namespace_id: params[:namespace_id])
end
@@ -100,6 +106,16 @@ class Import::BulkImportsController < ApplicationController
params.permit(access_token_key, url_key)
end
+ def validate_configure_params!
+ client = BulkImports::Clients::HTTP.new(
+ url: credentials[:url],
+ token: credentials[:access_token]
+ )
+
+ client.validate_instance_version!
+ client.validate_import_scopes!
+ end
+
def create_params
params.permit(bulk_import: bulk_import_params)[:bulk_import]
end
@@ -115,11 +131,12 @@ class Import::BulkImportsController < ApplicationController
destination_name
destination_slug
destination_namespace
+ migrate_projects
]
end
- def ensure_group_import_enabled
- render_404 unless ::BulkImports::Features.enabled?
+ def ensure_bulk_import_enabled
+ render_404 unless Gitlab::CurrentSettings.bulk_import_enabled?
end
def access_token_key
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index cb58b5974ca..9a8f6a74653 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -150,7 +150,7 @@ class Import::GithubController < Import::BaseController
end
def client_repos_response
- @client_repos_response ||= client_proxy.repos(sanitized_filter_param, pagination_options)
+ @client_repos_response ||= client_proxy.repos(sanitized_filter_param, fetch_repos_options)
end
def client_repos
@@ -160,7 +160,11 @@ class Import::GithubController < Import::BaseController
def sanitized_filter_param
super
- @filter = @filter&.tr(' ', '')&.tr(':', '')
+ @filter = sanitize_query_param(@filter)
+ end
+
+ def sanitize_query_param(value)
+ value.to_s.first(255).gsub(/[ :]/, '')
end
def verify_import_enabled
@@ -222,6 +226,10 @@ class Import::GithubController < Import::BaseController
head :too_many_requests
end
+ def fetch_repos_options
+ pagination_options.merge(relation_options)
+ end
+
def pagination_options
{
before: params[:before].presence,
@@ -233,6 +241,13 @@ class Import::GithubController < Import::BaseController
per_page: PAGE_LENGTH
}
end
+
+ def relation_options
+ {
+ relation_type: params[:relation_type],
+ organization_login: sanitize_query_param(params[:organization_login])
+ }
+ end
end
Import::GithubController.prepend_mod_with('Import::GithubController')
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index 8650b6cbc6f..5bd3b74af1f 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -186,7 +186,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
persist_accepted_terms_if_required(user) if new_user
store_after_sign_up_path_for_user if intent_to_register?
- sign_in_and_redirect(user, event: :authentication)
+ sign_in_and_redirect_or_confirm_identity(user, auth_user, new_user)
end
else
fail_login(user)
@@ -306,7 +306,6 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
end
def persist_accepted_terms_if_required(user)
- return unless Feature.enabled?(:update_oauth_registration_flow)
return unless user.persisted?
return unless Gitlab::CurrentSettings.current_application_settings.enforce_terms?
@@ -317,6 +316,11 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def store_after_sign_up_path_for_user
store_location_for(:user, users_sign_up_welcome_path)
end
+
+ # overridden in EE
+ def sign_in_and_redirect_or_confirm_identity(user, _, _)
+ sign_in_and_redirect(user, event: :authentication)
+ end
end
OmniauthCallbacksController.prepend_mod_with('OmniauthCallbacksController')
diff --git a/app/controllers/profiles/avatars_controller.rb b/app/controllers/profiles/avatars_controller.rb
index d9e4b9a149d..55a2904ce83 100644
--- a/app/controllers/profiles/avatars_controller.rb
+++ b/app/controllers/profiles/avatars_controller.rb
@@ -6,7 +6,7 @@ class Profiles::AvatarsController < Profiles::ApplicationController
def destroy
@user = current_user
- Users::UpdateService.new(current_user, user: @user).execute { |user| user.remove_avatar! }
+ Users::UpdateService.new(current_user, user: @user).execute(&:remove_avatar!)
redirect_to profile_path, status: :found
end
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index 03b7cc9f892..c36f03d3e69 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -206,7 +206,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
def webauthn_options
WebAuthn::Credential.options_for_create(
user: { id: current_user.webauthn_xid, name: current_user.username },
- exclude: current_user.webauthn_registrations.map { |c| c.credential_xid },
+ exclude: current_user.webauthn_registrations.map(&:credential_xid),
authenticator_selection: { user_verification: 'discouraged' },
rp: { name: 'GitLab' }
)
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index c3dcde38d09..3201538a393 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -82,6 +82,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
def raw
return render_404 unless zip_artifact?
+ return render_404 unless artifacts_file
path = Gitlab::Ci::Build::Artifacts::Path.new(params[:path])
diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb
index ef20c71cd77..000203079cc 100644
--- a/app/controllers/projects/autocomplete_sources_controller.rb
+++ b/app/controllers/projects/autocomplete_sources_controller.rb
@@ -5,7 +5,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
before_action :authorize_read_crm_contact!, only: :contacts
feature_category :team_planning, [:issues, :labels, :milestones, :commands, :contacts]
- feature_category :code_review, [:merge_requests]
+ feature_category :code_review_workflow, [:merge_requests]
feature_category :users, [:members]
feature_category :source_code_management, [:snippets]
@@ -33,7 +33,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
end
def commands
- render json: autocomplete_service.commands(target, params[:type])
+ render json: autocomplete_service.commands(target)
end
def snippets
diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb
index 84e5d59a2c3..1942a5fef7b 100644
--- a/app/controllers/projects/ci/pipeline_editor_controller.rb
+++ b/app/controllers/projects/ci/pipeline_editor_controller.rb
@@ -2,9 +2,6 @@
class Projects::Ci::PipelineEditorController < Projects::ApplicationController
before_action :check_can_collaborate!
- before_action do
- push_frontend_feature_flag(:schema_linting, @project)
- end
feature_category :pipeline_authoring
diff --git a/app/controllers/projects/design_management/designs/resized_image_controller.rb b/app/controllers/projects/design_management/designs/resized_image_controller.rb
index 50a997f32db..6bf304419e1 100644
--- a/app/controllers/projects/design_management/designs/resized_image_controller.rb
+++ b/app/controllers/projects/design_management/designs/resized_image_controller.rb
@@ -8,12 +8,13 @@ module Projects
include SendFileUpload
before_action :validate_size!
+ before_action :validate_sha!
skip_before_action :default_cache_headers, only: :show
def show
relation = design.actions
- relation = relation.up_to_version(sha) if sha
+ relation = relation.up_to_version(version) if version
action = relation.most_recent.first
return render_404 unless action
@@ -37,9 +38,19 @@ module Projects
render_404 unless ::DesignManagement::DESIGN_IMAGE_SIZES.include?(size)
end
+ def validate_sha!
+ render_404 if sha && version.blank?
+ end
+
def size
params[:id]
end
+
+ def version
+ return if sha.blank?
+
+ @version ||= design.versions.find_by_sha(sha)
+ end
end
end
end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 537fd3854c4..ea1288c0b20 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -299,6 +299,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def authorize_update_environment!
access_denied! unless can?(current_user, :update_environment, environment)
end
+
+ def append_info_to_payload(payload)
+ super
+
+ return unless Feature.enabled?(:environments_search_logging) && params[:search].present?
+
+ # Merging to :metadata will ensure these are logged as top level keys
+ payload[:metadata] ||= {}
+ payload[:metadata]['meta.environment.search'] = params[:search]
+ end
end
Projects::EnvironmentsController.prepend_mod_with('Projects::EnvironmentsController')
diff --git a/app/controllers/projects/google_cloud/deployments_controller.rb b/app/controllers/projects/google_cloud/deployments_controller.rb
index 041486eb2fb..fae8dbd59c7 100644
--- a/app/controllers/projects/google_cloud/deployments_controller.rb
+++ b/app/controllers/projects/google_cloud/deployments_controller.rb
@@ -75,6 +75,13 @@ The `deploy-to-cloud-run` job:
* `GCP_PROJECT_ID`
* `GCP_SERVICE_ACCOUNT_KEY`
* Job definition can be found at: https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/library
+* Uses CI/CD variables to configure the deployment. You can override the default values by adding these variables:
+ * `GCP_CLOUD_RUN_MAX_INSTANCES`
+ * `GCP_CLOUD_RUN_MIN_INSTANCES`
+ * `GCP_CLOUD_RUN_CONCURRENCY`
+ * `GCP_CLOUD_RUN_CPU`
+ * `GCP_CLOUD_RUN_MEMORY`
+ * `GCP_CLOUD_RUN_TIMEOUT`
This pipeline definition has been committed to the branch `#{branch_name}`.
You may modify the pipeline definition further or accept the changes as-is if suitable.
diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index 08eebfa0e4b..451f1d1363b 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -2,13 +2,13 @@
class Projects::GroupLinksController < Projects::ApplicationController
layout 'project_settings'
- before_action :authorize_admin_project!
+ before_action :authorize_admin_project!, except: [:destroy]
+ before_action :authorize_admin_project_group_link!, only: [:destroy]
before_action :authorize_admin_project_member!, only: [:update]
feature_category :subgroups
def update
- group_link = @project.project_group_links.find(params[:id])
Projects::GroupLinks::UpdateService.new(group_link, current_user).execute(group_link_params)
if group_link.expires?
@@ -22,13 +22,15 @@ class Projects::GroupLinksController < Projects::ApplicationController
end
def destroy
- group_link = project.project_group_links.find(params[:id])
-
::Projects::GroupLinks::DestroyService.new(project, current_user).execute(group_link)
respond_to do |format|
format.html do
- redirect_to project_project_members_path(project), status: :found
+ if can?(current_user, :admin_group, group_link.group)
+ redirect_to group_path(group_link.group), status: :found
+ elsif can?(current_user, :admin_project, group_link.project)
+ redirect_to project_project_members_path(project), status: :found
+ end
end
format.js { head :ok }
end
@@ -36,6 +38,15 @@ class Projects::GroupLinksController < Projects::ApplicationController
protected
+ def authorize_admin_project_group_link!
+ render_404 unless can?(current_user, :admin_project_group_link, group_link)
+ end
+
+ def group_link
+ @project.project_group_links.find(params[:id])
+ end
+ strong_memoize_attr :group_link
+
def group_link_params
params.require(:group_link).permit(:group_access, :expires_at)
end
diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb
index 3842a88d15b..8e4fbf24ca2 100644
--- a/app/controllers/projects/incidents_controller.rb
+++ b/app/controllers/projects/incidents_controller.rb
@@ -10,6 +10,7 @@ class Projects::IncidentsController < Projects::ApplicationController
push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc, @project&.work_items_mvc_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?)
+ push_frontend_feature_flag(:incident_event_tags, project)
end
feature_category :incident_management
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 631e697dd2f..06c16297ce8 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -27,6 +27,10 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :set_issuables_index, if: ->(c) {
SET_ISSUABLES_INDEX_ONLY_ACTIONS.include?(c.action_name.to_sym) && !index_html_request?
}
+ before_action :check_search_rate_limit!, if: ->(c) {
+ SET_ISSUABLES_INDEX_ONLY_ACTIONS.include?(c.action_name.to_sym) && !index_html_request? &&
+ params[:search].present? && Feature.enabled?(:rate_limit_issuable_searches)
+ }
# Allow write(create) issue
before_action :authorize_create_issue!, only: [:new, :create]
@@ -59,7 +63,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_force_frontend_feature_flag(:work_items_mvc, project&.work_items_mvc_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?)
push_frontend_feature_flag(:epic_widget_edit_confirmation, project)
- push_frontend_feature_flag(:use_iid_in_work_items_path, project)
+ push_frontend_feature_flag(:use_iid_in_work_items_path, project&.group)
push_force_frontend_feature_flag(:work_items_create_from_markdown, project&.work_items_create_from_markdown_feature_flag_enabled?)
end
@@ -436,7 +440,7 @@ class Projects::IssuesController < Projects::ApplicationController
def create_vulnerability_issue_feedback(issue); end
def redirect_if_work_item
- return unless allowed_work_item?
+ return unless use_work_items_path?(issue)
if Feature.enabled?(:use_iid_in_work_items_path, project.group)
redirect_to project_work_items_path(project, issue.iid, params: request.query_parameters.merge(iid_path: true))
@@ -444,10 +448,6 @@ class Projects::IssuesController < Projects::ApplicationController
redirect_to project_work_items_path(project, issue.id, params: request.query_parameters)
end
end
-
- def allowed_work_item?
- issue.task?
- end
end
Projects::IssuesController.prepend_mod_with('Projects::IssuesController')
diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb
index d8da448a323..be44c78ac9d 100644
--- a/app/controllers/projects/merge_requests/application_controller.rb
+++ b/app/controllers/projects/merge_requests/application_controller.rb
@@ -5,7 +5,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
before_action :merge_request
before_action :authorize_read_merge_request!
- feature_category :code_review
+ feature_category :code_review_workflow
private
@@ -13,6 +13,10 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
@issuable =
@merge_request ||=
merge_request_includes(@project.merge_requests).find_by_iid!(params[:id])
+
+ return render_404 unless can?(current_user, :read_merge_request, @issuable)
+
+ @issuable
end
def merge_request_includes(association)
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 83377f67723..1c546d70df9 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -36,15 +36,17 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
diff_options_hash[:paths] = params[:paths] if params[:paths]
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
+
+ unfoldable_positions = Gitlab::Metrics.measure(:diffs_unfoldable_positions) do
+ @merge_request.note_positions_for_paths(diffs.diff_file_paths, current_user).unfoldable
+ end
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,
- merge_conflicts_in_diff: display_merge_conflicts_in_diff?
+ pagination_data: diffs.pagination_data
}
# NOTE: Any variables that would affect the resulting json needs to be added to the cache_context to avoid stale cache issues.
@@ -56,16 +58,22 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
params[:expanded],
params[:page],
params[:per_page],
- options[:merge_ref_head_diff],
- options[:merge_conflicts_in_diff]
+ options[:merge_ref_head_diff]
]
return unless stale?(etag: [cache_context + diff_options_hash.fetch(:paths, []), diffs])
- diffs.unfold_diff_files(unfoldable_positions)
- diffs.write_cache
+ Gitlab::Metrics.measure(:diffs_unfold) do
+ diffs.unfold_diff_files(unfoldable_positions)
+ end
+
+ Gitlab::Metrics.measure(:diffs_write_cache) do
+ diffs.write_cache
+ end
- render json: PaginatedDiffSerializer.new(current_user: current_user).represent(diffs, options)
+ Gitlab::Metrics.measure(:diffs_render) do
+ render json: PaginatedDiffSerializer.new(current_user: current_user).represent(diffs, options)
+ end
end
# rubocop: enable Metrics/AbcSize
@@ -74,8 +82,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?,
- merge_conflicts_in_diff: display_merge_conflicts_in_diff?
+ merge_ref_head_diff: render_merge_ref_head_diff?
)
render json: DiffsMetadataSerializer.new(project: @merge_request.project, current_user: current_user)
@@ -103,8 +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?,
- merge_conflicts_in_diff: display_merge_conflicts_in_diff?
+ merge_ref_head_diff: render_merge_ref_head_diff?
)
options[:context_commits] = @merge_request.recent_context_commits
@@ -232,8 +238,4 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter
.track_mr_diffs_single_file_action(merge_request: @merge_request, user: current_user)
end
-
- def display_merge_conflicts_in_diff?
- Feature.enabled?(:display_merge_conflicts_in_diff, @merge_request.project)
- end
end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 3ab1f7d1d32..b0920b3fbdb 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -28,6 +28,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
:codequality_mr_diff_reports
]
before_action :set_issuables_index, only: [:index]
+ before_action :check_search_rate_limit!, only: [:index], if: -> {
+ params[:search].present? && Feature.enabled?(:rate_limit_issuable_searches)
+ }
before_action :authenticate_user!, only: [:assign_related_issues]
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
@@ -37,7 +40,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
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)
@@ -52,7 +54,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
after_action :log_merge_request_show, only: [:show, :diffs]
- feature_category :code_review, [
+ feature_category :code_review_workflow, [
:assign_related_issues, :bulk_update, :cancel_auto_merge,
:commit_change_content, :commits, :context_commits, :destroy,
:discussions, :edit, :index, :merge, :rebase, :remove_wip,
@@ -387,13 +389,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
format.patch do
- break render_404 unless @merge_request.diff_refs
+ next render_404 unless @merge_request.diff_refs
send_git_patch @project.repository, @merge_request.diff_refs
end
format.diff do
- break render_404 unless @merge_request.diff_refs
+ next render_404 unless @merge_request.diff_refs
send_git_diff @project.repository, @merge_request.diff_refs
end
@@ -512,15 +514,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def check_user_can_push_to_source_branch!
- return access_denied! unless @merge_request.source_branch_exists?
+ result = MergeRequests::RebaseService
+ .new(project: @merge_request.source_project, current_user: current_user)
+ .validate(@merge_request)
- access_check = ::Gitlab::UserAccess
- .new(current_user, container: @merge_request.source_project)
- .can_push_to_branch?(@merge_request.source_branch)
+ return if result.success?
- access_denied! unless access_check
-
- access_denied! unless merge_request.permits_force_push?
+ render json: { merge_error: result.message }, status: :forbidden
end
def merge_access_check
diff --git a/app/controllers/projects/ml/experiments_controller.rb b/app/controllers/projects/ml/experiments_controller.rb
index c82a959d612..1e1c4b1587c 100644
--- a/app/controllers/projects/ml/experiments_controller.rb
+++ b/app/controllers/projects/ml/experiments_controller.rb
@@ -7,10 +7,11 @@ module Projects
feature_category :mlops
- MAX_PER_PAGE = 20
+ MAX_EXPERIMENTS_PER_PAGE = 20
+ MAX_CANDIDATES_PER_PAGE = 30
def index
- @experiments = ::Ml::Experiment.by_project_id(@project.id).page(params[:page]).per(MAX_PER_PAGE)
+ @experiments = ::Ml::Experiment.by_project_id(@project.id).page(params[:page]).per(MAX_EXPERIMENTS_PER_PAGE)
end
def show
@@ -18,7 +19,26 @@ module Projects
return redirect_to project_ml_experiments_path(@project) unless @experiment.present?
- @candidates = @experiment.candidates&.including_metrics_and_params
+ page = params[:page].to_i
+ page = 1 if page == 0
+
+ @candidates = @experiment.candidates
+ .including_relationships
+ .page(page)
+ .per(MAX_CANDIDATES_PER_PAGE)
+
+ return unless @candidates
+
+ return redirect_to(url_for(page: @candidates.total_pages)) if @candidates.out_of_range?
+
+ @pagination = {
+ page: page,
+ is_last_page: @candidates.last_page?,
+ per_page: MAX_CANDIDATES_PER_PAGE,
+ total_items: @candidates.total_count
+ }
+
+ @candidates.each(&:artifact_lazy)
end
private
diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb
index 0e990b64cd6..db0762a6cff 100644
--- a/app/controllers/projects/pages_controller.rb
+++ b/app/controllers/projects/pages_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Projects::PagesController < Projects::ApplicationController
- layout 'project_settings'
+ layout :resolve_layout
before_action :require_pages_enabled!
before_action :authorize_read_pages!, only: [:show]
@@ -10,6 +10,10 @@ class Projects::PagesController < Projects::ApplicationController
feature_category :pages
+ before_action do
+ push_frontend_feature_flag(:show_pages_in_deployments_menu, current_user, type: :experiment)
+ end
+
def new
@pipeline_wizard_data = {
project_path: @project.full_path,
@@ -64,6 +68,10 @@ class Projects::PagesController < Projects::ApplicationController
private
+ def resolve_layout
+ 'project_settings' unless Feature.enabled?(:show_pages_in_deployments_menu, current_user, type: :experiment)
+ end
+
def project_params
params.require(:project).permit(project_params_attributes)
end
diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb
index 69a540158c6..442110d1044 100644
--- a/app/controllers/projects/protected_refs_controller.rb
+++ b/app/controllers/projects/protected_refs_controller.rb
@@ -22,7 +22,10 @@ class Projects::ProtectedRefsController < Projects::ApplicationController
flash[:alert] = protected_ref.errors.full_messages.join(', ').html_safe
end
- redirect_to_repository_settings(@project, anchor: params[:update_section])
+ respond_to do |format|
+ format.html { redirect_to_repository_settings(@project, anchor: params[:update_section]) }
+ format.json { head :ok }
+ end
end
def show
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index cf07de4dc29..f8133c5836d 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -4,6 +4,7 @@ module Projects
module Settings
class CiCdController < Projects::ApplicationController
include RunnerSetupScripts
+ include ZuoraCSP
NUMBER_OF_RUNNERS_PER_PAGE = 20
@@ -21,13 +22,11 @@ module Projects
@entity = :project
@variable_limit = ::Plan.default.actual_limits.project_ci_variables
- if Feature.enabled?(:ci_pipeline_triggers_settings_vue_ui, @project)
- triggers = ::Ci::TriggerSerializer.new.represent(
- @project.triggers, current_user: current_user, project: @project
- )
+ triggers = ::Ci::TriggerSerializer.new.represent(
+ @project.triggers, current_user: current_user, project: @project
+ )
- @triggers_json = Gitlab::Json.dump(triggers)
- end
+ @triggers_json = Gitlab::Json.dump(triggers)
render
end
diff --git a/app/controllers/projects/settings/merge_requests_controller.rb b/app/controllers/projects/settings/merge_requests_controller.rb
index 93e10695767..f09e324f574 100644
--- a/app/controllers/projects/settings/merge_requests_controller.rb
+++ b/app/controllers/projects/settings/merge_requests_controller.rb
@@ -9,7 +9,7 @@ module Projects
before_action :present_project, only: [:edit]
before_action :authorize_admin_project!
- feature_category :code_review
+ feature_category :code_review_workflow
def update
result = ::Projects::UpdateService.new(@project, current_user, project_params).execute
diff --git a/app/controllers/projects/work_items_controller.rb b/app/controllers/projects/work_items_controller.rb
index a118c6986f7..db9dca14aab 100644
--- a/app/controllers/projects/work_items_controller.rb
+++ b/app/controllers/projects/work_items_controller.rb
@@ -5,7 +5,7 @@ class Projects::WorkItemsController < Projects::ApplicationController
push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc, project&.work_items_mvc_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?)
- push_frontend_feature_flag(:use_iid_in_work_items_path, project)
+ push_frontend_feature_flag(:use_iid_in_work_items_path, project&.group)
end
feature_category :team_planning
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 886819fe778..ee2c268ff33 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -58,7 +58,7 @@ class ProjectsController < Projects::ApplicationController
feature_category :source_code_management, [:remove_fork, :housekeeping, :refs]
feature_category :team_planning, [:preview_markdown, :new_issuable_address]
feature_category :importers, [:export, :remove_export, :generate_new_export, :download_export]
- feature_category :code_review, [:unfoldered_environment_names]
+ feature_category :code_review_workflow, [:unfoldered_environment_names]
feature_category :portfolio_management, [:planning_hierarchy]
urgency :low, [:export, :remove_export, :generate_new_export, :download_export]
@@ -388,7 +388,7 @@ class ProjectsController < Projects::ApplicationController
def determine_layout
if [:new, :create].include?(action_name.to_sym)
- 'application'
+ 'dashboard'
elsif [:edit, :update].include?(action_name.to_sym)
'project_settings'
else
diff --git a/app/controllers/pwa_controller.rb b/app/controllers/pwa_controller.rb
index 8de1b10e1f1..bb47bdc8050 100644
--- a/app/controllers/pwa_controller.rb
+++ b/app/controllers/pwa_controller.rb
@@ -6,7 +6,7 @@ class PwaController < ApplicationController # rubocop:disable Gitlab/NamespacedC
feature_category :navigation
urgency :low
- skip_before_action :authenticate_user!
+ skip_before_action :authenticate_user!, :required_signup_info
def manifest
end
diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb
index 4a42632a980..cfb4e939b35 100644
--- a/app/controllers/registrations/welcome_controller.rb
+++ b/app/controllers/registrations/welcome_controller.rb
@@ -21,20 +21,10 @@ module Registrations
def update
result = ::Users::SignupService.new(current_user, update_params).execute
- if result[:status] == :success
+ if result.success?
track_event('successfully_submitted_form')
- return redirect_to issues_dashboard_path(assignee_username: current_user.username) if show_tasks_to_be_done?
-
- return redirect_to update_success_path if show_signup_onboarding?
-
- members = current_user.members
-
- if registering_from_invite?(members)
- redirect_to members_activity_path(members), notice: helpers.invite_accepted_notice(members.last)
- else
- redirect_to path_for_signed_in_user(current_user)
- end
+ redirect_to update_success_path
else
render :show
end
@@ -79,21 +69,35 @@ module Registrations
end
# overridden in EE
- def show_signup_onboarding?
+ def redirect_to_signup_onboarding?
false
end
- def show_tasks_to_be_done?
+ def redirect_for_tasks_to_be_done?
MemberTask.for_members(current_user.members).exists?
end
- # overridden in EE
def update_success_path
+ return issues_dashboard_path(assignee_username: current_user.username) if redirect_for_tasks_to_be_done?
+
+ return signup_onboarding_path if redirect_to_signup_onboarding?
+
+ members = current_user.members
+
+ if registering_from_invite?(members)
+ flash[:notice] = helpers.invite_accepted_notice(members.last)
+ members_activity_path(members)
+ else
+ # subscription registrations goes through here as well
+ path_for_signed_in_user(current_user)
+ end
end
# overridden in EE
- def track_event(category)
- end
+ def signup_onboarding_path; end
+
+ # overridden in EE
+ def track_event(action); end
end
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 11f9f1cf0c6..0800d635d92 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class RegistrationsController < Devise::RegistrationsController
- include Recaptcha::Verify
+ include Recaptcha::Adapters::ControllerMethods
include AcceptsPendingInvitations
include RecaptchaHelper
include InvisibleCaptchaOnSignup
@@ -121,7 +121,7 @@ class RegistrationsController < Devise::RegistrationsController
def after_sign_up_path_for(user)
Gitlab::AppLogger.info(user_created_message(confirmed: user.confirmed?))
- users_sign_up_welcome_path(glm_tracking_params)
+ after_sign_up_path
end
def after_inactive_sign_up_path_for(resource)
@@ -131,7 +131,7 @@ class RegistrationsController < Devise::RegistrationsController
# when email confirmation is enabled, path to redirect is saved
# after user confirms and comes back, he will be redirected
- store_location_for(:redirect, users_sign_up_welcome_path(glm_tracking_params))
+ store_location_for(:redirect, after_sign_up_path)
return identity_verification_redirect_path if custom_confirmation_enabled?
@@ -141,6 +141,10 @@ class RegistrationsController < Devise::RegistrationsController
private
+ def after_sign_up_path
+ users_sign_up_welcome_path(glm_tracking_params)
+ end
+
def ensure_destroy_prerequisites_met
if current_user.solo_owned_groups.present?
redirect_to profile_account_path,
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 66968b34380..38c773fa31d 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -36,7 +36,6 @@ class SearchController < ApplicationController
before_action only: :show do
update_scope_for_code_search
end
- before_action :elasticsearch_in_use, only: :show
rescue_from ActiveRecord::QueryCanceled, with: :render_timeout
layout 'search'
@@ -47,7 +46,7 @@ class SearchController < ApplicationController
def show
@project = search_service.project
@group = search_service.group
- @search_service = Gitlab::View::Presenter::Factory.new(search_service, current_user: current_user).fabricate!
+ @search_service_presenter = Gitlab::View::Presenter::Factory.new(search_service, current_user: current_user).fabricate!
return unless search_term_valid?
@@ -56,14 +55,14 @@ class SearchController < ApplicationController
@search_term = params[:search]
@sort = params[:sort] || default_sort
- @search_level = @search_service.level
+ @search_level = @search_service_presenter.level
@search_type = search_type
@global_search_duration_s = Benchmark.realtime do
- @scope = @search_service.scope
- @search_results = @search_service.search_results
- @search_objects = @search_service.search_objects
- @search_highlight = @search_service.search_highlight
+ @scope = @search_service_presenter.scope
+ @search_results = @search_service_presenter.search_results
+ @search_objects = @search_service_presenter.search_objects
+ @search_highlight = @search_service_presenter.search_highlight
end
Gitlab::Metrics::GlobalSearchSlis.record_apdex(
@@ -119,11 +118,6 @@ class SearchController < ApplicationController
def opensearch
end
- def elasticsearch_in_use
- search_service.respond_to?(:use_elasticsearch?) && search_service.use_elasticsearch?
- end
- strong_memoize_attr :elasticsearch_in_use
-
private
def update_scope_for_code_search
@@ -218,24 +212,7 @@ class SearchController < ApplicationController
def check_scope_global_search_enabled
return unless search_service.global_search?
- search_allowed = case params[:scope]
- when 'blobs'
- Feature.enabled?(:global_search_code_tab, current_user, type: :ops)
- when 'commits'
- Feature.enabled?(:global_search_commits_tab, current_user, type: :ops)
- when 'issues'
- Feature.enabled?(:global_search_issues_tab, current_user, type: :ops)
- when 'merge_requests'
- Feature.enabled?(:global_search_merge_requests_tab, current_user, type: :ops)
- when 'wiki_blobs'
- Feature.enabled?(:global_search_wiki_tab, current_user, type: :ops)
- when 'users'
- Feature.enabled?(:global_search_users_tab, current_user, type: :ops)
- else
- true
- end
-
- return if search_allowed
+ return if search_service.global_search_enabled_for_scope?
redirect_to search_path, alert: _('Global Search is disabled for this scope')
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index c20a9aa4485..699dcf1adac 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -4,8 +4,8 @@ class SessionsController < Devise::SessionsController
include InternalRedirect
include AuthenticatesWithTwoFactor
include Devise::Controllers::Rememberable
- include Recaptcha::ClientHelper
- include Recaptcha::Verify
+ include Recaptcha::Adapters::ViewMethods
+ include Recaptcha::Adapters::ControllerMethods
include RendersLdapServers
include KnownSignIn
include Gitlab::Utils::StrongMemoize
diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb
index 9e23eef4178..81f80aa4224 100644
--- a/app/controllers/snippets/notes_controller.rb
+++ b/app/controllers/snippets/notes_controller.rb
@@ -13,7 +13,7 @@ class Snippets::NotesController < ApplicationController
private
def note
- @note ||= snippet.notes.inc_relations_for_view.find(params[:id])
+ @note ||= snippet.notes.inc_relations_for_view(snippet).find(params[:id])
end
alias_method :awardable, :note
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index 66f715f32af..ea99aa12350 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -15,6 +15,7 @@ class UploadsController < ApplicationController
"personal_snippet" => PersonalSnippet,
"projects/topic" => Projects::Topic,
'alert_management_metric_image' => ::AlertManagement::MetricImage,
+ "achievements/achievement" => Achievements::Achievement,
nil => PersonalSnippet
}.freeze
@@ -61,6 +62,8 @@ class UploadsController < ApplicationController
true
when ::AlertManagement::MetricImage
can?(current_user, :read_alert_management_metric_image, model.alert)
+ when ::Achievements::Achievement
+ true
else
can?(current_user, "read_#{model.class.underscore}".to_sym, model)
end
@@ -92,7 +95,7 @@ class UploadsController < ApplicationController
def cache_settings
case model
- when User, Appearance, Projects::Topic
+ when User, Appearance, Projects::Topic, Achievements::Achievement
[5.minutes, { public: true, must_revalidate: false }]
when Project, Group
[5.minutes, { private: true, must_revalidate: true }]
diff --git a/app/events/pages_domains/pages_domain_created_event.rb b/app/events/pages_domains/pages_domain_created_event.rb
index a86718f4681..430a3e0100c 100644
--- a/app/events/pages_domains/pages_domain_created_event.rb
+++ b/app/events/pages_domains/pages_domain_created_event.rb
@@ -9,6 +9,7 @@ module PagesDomains
'project_id' => { 'type' => 'integer' },
'namespace_id' => { 'type' => 'integer' },
'root_namespace_id' => { 'type' => 'integer' },
+ 'domain_id' => { 'type' => 'integer' },
'domain' => { 'type' => 'string' }
},
'required' => %w[project_id namespace_id root_namespace_id]
diff --git a/app/events/pages_domains/pages_domain_deleted_event.rb b/app/events/pages_domains/pages_domain_deleted_event.rb
index 7fe165a7249..3f32f5abe2a 100644
--- a/app/events/pages_domains/pages_domain_deleted_event.rb
+++ b/app/events/pages_domains/pages_domain_deleted_event.rb
@@ -9,6 +9,7 @@ module PagesDomains
'project_id' => { 'type' => 'integer' },
'namespace_id' => { 'type' => 'integer' },
'root_namespace_id' => { 'type' => 'integer' },
+ 'domain_id' => { 'type' => 'integer' },
'domain' => { 'type' => 'string' }
},
'required' => %w[project_id namespace_id root_namespace_id]
diff --git a/app/events/pages_domains/pages_domain_updated_event.rb b/app/events/pages_domains/pages_domain_updated_event.rb
index 641fb2f6a53..f7211420355 100644
--- a/app/events/pages_domains/pages_domain_updated_event.rb
+++ b/app/events/pages_domains/pages_domain_updated_event.rb
@@ -9,6 +9,7 @@ module PagesDomains
'project_id' => { 'type' => 'integer' },
'namespace_id' => { 'type' => 'integer' },
'root_namespace_id' => { 'type' => 'integer' },
+ 'domain_id' => { 'type' => 'integer' },
'domain' => { 'type' => 'string' }
},
'required' => %w[project_id namespace_id root_namespace_id]
diff --git a/app/finders/access_requests_finder.rb b/app/finders/access_requests_finder.rb
index 9b1407e2971..65e1934a39f 100644
--- a/app/finders/access_requests_finder.rb
+++ b/app/finders/access_requests_finder.rb
@@ -18,7 +18,11 @@ class AccessRequestsFinder
def execute!(current_user)
raise Gitlab::Access::AccessDeniedError unless can_see_access_requests?(current_user)
- source.requesters
+ if Feature.enabled?(:project_members_index_by_project_namespace, source)
+ source.namespace_requesters
+ else
+ source.requesters
+ end
end
private
diff --git a/app/finders/branches_finder.rb b/app/finders/branches_finder.rb
index a62d47071d4..dc7b9f6a0ce 100644
--- a/app/finders/branches_finder.rb
+++ b/app/finders/branches_finder.rb
@@ -6,11 +6,12 @@ class BranchesFinder < GitRefsFinder
end
def execute(gitaly_pagination: false)
- if gitaly_pagination && names.blank? && search.blank?
+ if gitaly_pagination && names.blank? && search.blank? && regex.blank?
repository.branches_sorted_by(sort, pagination_params)
else
branches = repository.branches_sorted_by(sort)
branches = by_search(branches)
+ branches = by_regex(branches)
by_names(branches)
end
end
@@ -29,6 +30,11 @@ class BranchesFinder < GitRefsFinder
@params[:per_page].presence
end
+ def regex
+ @params[:regex].to_s.presence
+ end
+ strong_memoize_attr :regex
+
def page_token
"#{Gitlab::Git::BRANCH_REF_PREFIX}#{@params[:page_token]}" if @params[:page_token]
end
@@ -45,4 +51,14 @@ class BranchesFinder < GitRefsFinder
branch_names.include?(branch.name)
end
end
+
+ def by_regex(branches)
+ return branches unless regex
+
+ branch_filter = ::Gitlab::UntrustedRegexp.new(regex)
+
+ branches.select do |branch|
+ branch_filter.match?(branch.name)
+ end
+ end
end
diff --git a/app/finders/ci/pipelines_finder.rb b/app/finders/ci/pipelines_finder.rb
index 4c47517299a..a2d1805286d 100644
--- a/app/finders/ci/pipelines_finder.rb
+++ b/app/finders/ci/pipelines_finder.rb
@@ -155,8 +155,7 @@ module Ci
def by_name(items)
return items unless
- Feature.enabled?(:pipeline_name, project) &&
- Feature.enabled?(:pipeline_name_search, project) &&
+ Feature.enabled?(:pipeline_name_search, project) &&
params[:name].present?
items.for_name(params[:name])
diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb
index 136d23939e2..bc1dcb3ad5f 100644
--- a/app/finders/ci/runners_finder.rb
+++ b/app/finders/ci/runners_finder.rb
@@ -37,7 +37,7 @@ module Ci
private
def search!
- if @project && Feature.enabled?(:on_demand_scans_runner_tags, @project)
+ if @project
project_runners
elsif @group
group_runners
diff --git a/app/finders/git_refs_finder.rb b/app/finders/git_refs_finder.rb
index 0492dd9934f..3c8d53051d6 100644
--- a/app/finders/git_refs_finder.rb
+++ b/app/finders/git_refs_finder.rb
@@ -49,7 +49,7 @@ class GitRefsFinder
def regex_search?
Regexp.union('^', '$', '*') === search
end
- strong_memoize_attr :regex_search?, :regex_search
+ strong_memoize_attr :regex_search?
def unescape_regex_operators(regex_string)
regex_string.sub('\^', '^').gsub('\*', '.*?').sub('\$', '$')
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 5fcb81949ee..13b7137da48 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -248,7 +248,10 @@ class IssuableFinder
end
def init_collection
- klass.all
+ return klass.all if params.user_can_see_all_issuables?
+
+ # Only admins and auditors can see hidden issuables, for other users we filter out hidden issuables
+ klass.without_hidden
end
def default_or_simple_sort?
@@ -407,7 +410,8 @@ class IssuableFinder
Issuables::LabelFilter.new(
params: original_params,
project: params.project,
- group: params.group
+ group: params.group,
+ or_filters_enabled: or_filters_enabled?
)
end
end
diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb
index 32d50802537..e59c2224594 100644
--- a/app/finders/issuable_finder/params.rb
+++ b/app/finders/issuable_finder/params.rb
@@ -195,6 +195,11 @@ class IssuableFinder
project || group
end
+ def user_can_see_all_issuables?
+ Ability.allowed?(current_user, :read_all_resources)
+ end
+ strong_memoize_attr :user_can_see_all_issuables?
+
private
def projects_public_or_visible_to_user
diff --git a/app/finders/issuables/label_filter.rb b/app/finders/issuables/label_filter.rb
index 4e9c964e51c..81b86a1de32 100644
--- a/app/finders/issuables/label_filter.rb
+++ b/app/finders/issuables/label_filter.rb
@@ -14,6 +14,7 @@ module Issuables
def filter(issuables)
filtered = by_label(issuables)
+ filtered = by_label_union(filtered)
by_negated_label(filtered)
end
@@ -27,18 +28,29 @@ module Issuables
def by_label(issuables)
return issuables unless label_names_from_params.present?
- target_model = issuables.base_class
-
if filter_by_no_label?
- issuables.where(label_link_query(target_model).arel.exists.not)
+ issuables.where(label_link_query(issuables).arel.exists.not)
elsif filter_by_any_label?
- issuables.where(label_link_query(target_model).arel.exists)
+ issuables.where(label_link_query(issuables).arel.exists)
else
issuables_with_selected_labels(issuables, label_names_from_params)
end
end
# rubocop: enable CodeReuse/ActiveRecord
+ # rubocop: disable CodeReuse/ActiveRecord
+ def by_label_union(issuables)
+ return issuables unless or_filters_enabled? && label_names_from_or_params.present?
+
+ if root_namespace
+ all_label_ids = find_label_ids(label_names_from_or_params).flatten
+ issuables.where(label_link_query(issuables, label_ids: all_label_ids).arel.exists)
+ else
+ issuables.where(label_link_query(issuables, label_names: label_names_from_or_params).arel.exists)
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
def by_negated_label(issuables)
return issuables unless label_names_from_not_params.present?
@@ -55,19 +67,17 @@ module Issuables
# rubocop: disable CodeReuse/ActiveRecord
def issuables_with_selected_labels(issuables, label_names)
- target_model = issuables.base_class
-
if root_namespace
all_label_ids = find_label_ids(label_names)
# Found less labels in the DB than we were searching for. Return nothing.
return issuables.none if all_label_ids.size != label_names.size
all_label_ids.each do |label_ids|
- issuables = issuables.where(label_link_query(target_model, label_ids: label_ids).arel.exists)
+ issuables = issuables.where(label_link_query(issuables, label_ids: label_ids).arel.exists)
end
else
label_names.each do |label_name|
- issuables = issuables.where(label_link_query(target_model, label_names: label_name).arel.exists)
+ issuables = issuables.where(label_link_query(issuables, label_names: label_name).arel.exists)
end
end
@@ -77,16 +87,14 @@ module Issuables
# rubocop: disable CodeReuse/ActiveRecord
def issuables_without_selected_labels(issuables, label_names)
- target_model = issuables.base_class
-
if root_namespace
label_ids = find_label_ids(label_names).flatten(1)
return issuables if label_ids.empty?
- issuables.where(label_link_query(target_model, label_ids: label_ids).arel.exists.not)
+ issuables.where(label_link_query(issuables, label_ids: label_ids).arel.exists.not)
else
- issuables.where(label_link_query(target_model, label_names: label_names).arel.exists.not)
+ issuables.where(label_link_query(issuables, label_names: label_names).arel.exists.not)
end
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -134,7 +142,9 @@ module Issuables
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
- def label_link_query(target_model, label_ids: nil, label_names: nil)
+ def label_link_query(issuables, label_ids: nil, label_names: nil)
+ target_model = issuables.base_class
+
relation = LabelLink.by_target_for_exists_query(target_model.name, target_model.arel_table['id'], label_ids)
relation = relation.joins(:label).where(labels: { name: label_names }) if label_names
@@ -150,6 +160,14 @@ module Issuables
end
end
+ def label_names_from_or_params
+ return if or_params.blank? || or_params[:label_name].blank?
+
+ strong_memoize(:label_names_from_or_params) do
+ split_label_names(or_params[:label_name])
+ end
+ end
+
def label_names_from_not_params
return if not_params.blank? || not_params[:label_name].blank?
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index e12dce744b5..bd81f06f93b 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -49,7 +49,7 @@ class IssuesFinder < IssuableFinder
# rubocop: disable CodeReuse/ActiveRecord
def with_confidentiality_access_check
- return model_class.all if params.user_can_see_all_issues?
+ return model_class.all if params.user_can_see_all_issuables?
# Only admins can see hidden issues, so for non-admins, we filter out any hidden issues
issues = model_class.without_hidden
diff --git a/app/finders/issues_finder/params.rb b/app/finders/issues_finder/params.rb
index 7f8acb79ed6..786bfbd4113 100644
--- a/app/finders/issues_finder/params.rb
+++ b/app/finders/issues_finder/params.rb
@@ -44,7 +44,7 @@ class IssuesFinder
if parent
Ability.allowed?(current_user, :read_confidential_issues, parent)
else
- user_can_see_all_issues?
+ user_can_see_all_issuables?
end
end
end
@@ -54,12 +54,6 @@ class IssuesFinder
current_user.blank?
end
-
- def user_can_see_all_issues?
- strong_memoize(:user_can_see_all_issues) do
- Ability.allowed?(current_user, :read_all_resources)
- end
- end
end
end
diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb
index de6eacbb1e0..de2a4ce3518 100644
--- a/app/finders/members_finder.rb
+++ b/app/finders/members_finder.rb
@@ -31,7 +31,11 @@ class MembersFinder
attr_reader :project, :current_user, :group
def find_members(include_relations)
- project_members = project.project_members
+ project_members = if Feature.enabled?(:project_members_index_by_project_namespace, project)
+ project.namespace_members
+ else
+ project.project_members
+ end
if params[:active_without_invites_and_requests].present?
project_members = project_members.active_without_invites_and_requests
diff --git a/app/finders/packages/go/version_finder.rb b/app/finders/packages/go/version_finder.rb
index 8500a441fb7..902c4a00a21 100644
--- a/app/finders/packages/go/version_finder.rb
+++ b/app/finders/packages/go/version_finder.rb
@@ -15,7 +15,7 @@ module Packages
@mod.project.repository.tags
.filter { |tag| semver_tag? tag }
.map { |tag| @mod.version_by(ref: tag) }
- .filter { |ver| ver.valid? }
+ .filter(&:valid?)
end
def find(target)
diff --git a/app/graphql/mutations/achievements/create.rb b/app/graphql/mutations/achievements/create.rb
new file mode 100644
index 00000000000..6cfe6c0e643
--- /dev/null
+++ b/app/graphql/mutations/achievements/create.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Achievements
+ class Create < BaseMutation
+ graphql_name 'AchievementsCreate'
+
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ field :achievement,
+ ::Types::Achievements::AchievementType,
+ null: true,
+ description: 'Achievement created.'
+
+ argument :namespace_id, ::Types::GlobalIDType[::Namespace],
+ required: true,
+ description: 'Namespace for the achievement.'
+
+ argument :name, GraphQL::Types::String,
+ required: true,
+ description: 'Name for the achievement.'
+
+ argument :avatar, ApolloUploadServer::Upload,
+ required: false,
+ description: 'Avatar for the achievement.'
+
+ argument :description, GraphQL::Types::String,
+ required: false,
+ description: 'Description of or notes for the achievement.'
+
+ argument :revokeable, GraphQL::Types::Boolean,
+ required: true,
+ description: 'Revokeability for the achievement.'
+
+ authorize :admin_achievement
+
+ def resolve(args)
+ namespace = authorized_find!(id: args[:namespace_id])
+
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, '`achievements` feature flag is disabled.' \
+ if Feature.disabled?(:achievements, namespace)
+
+ result = ::Achievements::CreateService.new(namespace: namespace,
+ current_user: current_user,
+ params: args).execute
+ { achievement: result.payload, errors: result.errors }
+ end
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id, expected_type: ::Namespace)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/boards/lists/base_update.rb b/app/graphql/mutations/boards/lists/base_update.rb
index 7962d9c85d4..c6e6b1c9bfe 100644
--- a/app/graphql/mutations/boards/lists/base_update.rb
+++ b/app/graphql/mutations/boards/lists/base_update.rb
@@ -10,7 +10,7 @@ module Mutations
argument :collapsed, GraphQL::Types::Boolean,
required: false,
- description: 'Indicates if the list is collapsed for this user.'
+ description: 'Indicates if the list is collapsed for the user.'
def resolve(list: nil, **args)
if list.nil? || !can_read_list?(list)
diff --git a/app/graphql/mutations/ci/job/play.rb b/app/graphql/mutations/ci/job/play.rb
index 99f62ea3e70..8bb69119a44 100644
--- a/app/graphql/mutations/ci/job/play.rb
+++ b/app/graphql/mutations/ci/job/play.rb
@@ -11,13 +11,21 @@ module Mutations
null: true,
description: 'Job after the mutation.'
+ argument :variables, [::Types::Ci::VariableInputType],
+ required: false,
+ default_value: [],
+ replace_null_with_default: true,
+ description: 'Variables to use when playing a manual job.'
+
authorize :update_build
- def resolve(id:)
+ def resolve(id:, variables:)
job = authorized_find!(id: id)
project = job.project
+ variables = variables.map(&:to_h)
+
+ ::Ci::PlayBuildService.new(project, current_user).execute(job, variables)
- ::Ci::PlayBuildService.new(project, current_user).execute(job)
{
job: job,
errors: errors_on_object(job)
diff --git a/app/graphql/mutations/ci/project_ci_cd_settings_update.rb b/app/graphql/mutations/ci/project_ci_cd_settings_update.rb
index 27b066ffcf6..934d62e92cf 100644
--- a/app/graphql/mutations/ci/project_ci_cd_settings_update.rb
+++ b/app/graphql/mutations/ci/project_ci_cd_settings_update.rb
@@ -15,7 +15,7 @@ module Mutations
argument :keep_latest_artifact, GraphQL::Types::Boolean,
required: false,
- description: 'Indicates if the latest artifact should be kept for this project.'
+ description: 'Indicates if the latest artifact should be kept for the project.'
argument :job_token_scope_enabled, GraphQL::Types::Boolean,
required: false,
diff --git a/app/graphql/mutations/clusters/agents/create.rb b/app/graphql/mutations/clusters/agents/create.rb
index deaa9c2d656..8be1e0e524a 100644
--- a/app/graphql/mutations/clusters/agents/create.rb
+++ b/app/graphql/mutations/clusters/agents/create.rb
@@ -12,7 +12,7 @@ module Mutations
argument :project_path, GraphQL::Types::ID,
required: true,
- description: 'Full path of the associated project for this cluster agent.'
+ description: 'Full path of the associated project for the cluster agent.'
argument :name, GraphQL::Types::String,
required: true,
diff --git a/app/graphql/mutations/commits/create.rb b/app/graphql/mutations/commits/create.rb
index 00ec64becc8..02e1e4c78bf 100644
--- a/app/graphql/mutations/commits/create.rb
+++ b/app/graphql/mutations/commits/create.rb
@@ -58,7 +58,7 @@ module Mutations
commit_message: message,
branch_name: branch,
start_branch: args[:start_branch] || branch,
- actions: actions.map { |action| action.to_h }
+ actions: actions.map(&:to_h)
}
result = ::Files::MultiService.new(project, current_user, attributes).execute
diff --git a/app/graphql/mutations/concerns/mutations/work_items/widgetable.rb b/app/graphql/mutations/concerns/mutations/work_items/widgetable.rb
index 445b2eb6441..508e1627032 100644
--- a/app/graphql/mutations/concerns/mutations/work_items/widgetable.rb
+++ b/app/graphql/mutations/concerns/mutations/work_items/widgetable.rb
@@ -18,7 +18,7 @@ module Mutations
# Cannot use prepare to use `.to_h` on each input due to
# https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87472#note_945199865
- widget_params.transform_values { |values| values.to_h }
+ widget_params.transform_values(&:to_h)
end
end
end
diff --git a/app/graphql/mutations/jira_import/start.rb b/app/graphql/mutations/jira_import/start.rb
index ea071c45bcf..2ba20e163a5 100644
--- a/app/graphql/mutations/jira_import/start.rb
+++ b/app/graphql/mutations/jira_import/start.rb
@@ -30,7 +30,7 @@ module Mutations
def resolve(project_path:, jira_project_key:, users_mapping:)
project = authorized_find!(project_path)
- mapping = users_mapping.to_ary.map { |map| map.to_hash }
+ mapping = users_mapping.to_ary.map(&:to_hash)
service_response = ::JiraImport::StartImportService
.new(context[:current_user], project, jira_project_key, mapping)
diff --git a/app/graphql/mutations/members/groups/bulk_update.rb b/app/graphql/mutations/members/groups/bulk_update.rb
new file mode 100644
index 00000000000..d0b19bd9634
--- /dev/null
+++ b/app/graphql/mutations/members/groups/bulk_update.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Members
+ module Groups
+ class BulkUpdate < ::Mutations::BaseMutation
+ graphql_name 'GroupMemberBulkUpdate'
+
+ include Gitlab::Utils::StrongMemoize
+
+ authorize :admin_group_member
+
+ field :group_members,
+ [Types::GroupMemberType],
+ null: true,
+ description: 'Group members after mutation.'
+
+ argument :group_id,
+ ::Types::GlobalIDType[::Group],
+ required: true,
+ description: 'Global ID of the group.'
+
+ argument :user_ids,
+ [::Types::GlobalIDType[::User]],
+ required: true,
+ description: 'Global IDs of the group members.'
+
+ argument :access_level,
+ ::Types::MemberAccessLevelEnum,
+ required: true,
+ description: 'Access level to update the members to.'
+
+ argument :expires_at,
+ Types::TimeType,
+ required: false,
+ description: 'Date and time the membership expires.'
+
+ MAX_MEMBERS_UPDATE_LIMIT = 50
+ MAX_MEMBERS_UPDATE_ERROR = "Count of members to be updated should be less than #{MAX_MEMBERS_UPDATE_LIMIT}."
+ INVALID_MEMBERS_ERROR = 'Only access level of direct members can be updated.'
+
+ def resolve(group_id:, **args)
+ result = ::Members::UpdateService.new(current_user, args.except(:user_ids)).execute(@updatable_group_members)
+
+ {
+ group_members: result[:members],
+ errors: Array.wrap(result[:message])
+ }
+ rescue Gitlab::Access::AccessDeniedError
+ {
+ errors: ["Unable to update members, please check user permissions."]
+ }
+ end
+
+ private
+
+ def ready?(**args)
+ group = authorized_find!(group_id: args[:group_id])
+ user_ids = args.fetch(:user_ids, {}).map(&:model_id)
+ @updatable_group_members = only_direct_group_members(group, user_ids)
+
+ if @updatable_group_members.size > MAX_MEMBERS_UPDATE_LIMIT
+ raise Gitlab::Graphql::Errors::InvalidMemberCountError, MAX_MEMBERS_UPDATE_ERROR
+ end
+
+ if @updatable_group_members.size != user_ids.size
+ raise Gitlab::Graphql::Errors::InvalidMembersError, INVALID_MEMBERS_ERROR
+ end
+
+ super
+ end
+
+ def find_object(group_id:)
+ GitlabSchema.object_from_id(group_id, expected_type: ::Group)
+ end
+
+ def only_direct_group_members(group, user_ids)
+ group
+ .members
+ .with_user(user_ids).to_a
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/merge_requests/accept.rb b/app/graphql/mutations/merge_requests/accept.rb
index ebd9e2b8fdd..64572091379 100644
--- a/app/graphql/mutations/merge_requests/accept.rb
+++ b/app/graphql/mutations/merge_requests/accept.rb
@@ -21,14 +21,14 @@ module Mutations
::Types::MergeStrategyEnum,
required: false,
as: :auto_merge_strategy,
- description: 'How to merge this merge request.'
+ description: 'How to merge the merge request.'
argument :commit_message, ::GraphQL::Types::String,
required: false,
description: 'Custom merge commit message.'
argument :sha, ::GraphQL::Types::String,
required: true,
- description: 'HEAD SHA at the time when this merge was requested.'
+ description: 'HEAD SHA at the time when the merge was requested.'
argument :squash_commit_message, ::GraphQL::Types::String,
required: false,
description: 'Custom squash commit message (if squash is true).'
diff --git a/app/graphql/mutations/notes/create/note.rb b/app/graphql/mutations/notes/create/note.rb
index 9b105b7fe1c..0f1be32d088 100644
--- a/app/graphql/mutations/notes/create/note.rb
+++ b/app/graphql/mutations/notes/create/note.rb
@@ -10,7 +10,7 @@ module Mutations
argument :discussion_id,
::Types::GlobalIDType[::Discussion],
required: false,
- description: 'Global ID of the discussion this note is in reply to.'
+ description: 'Global ID of the discussion the note is in reply to.'
argument :merge_request_diff_head_sha,
GraphQL::Types::String,
diff --git a/app/graphql/mutations/packages/bulk_destroy.rb b/app/graphql/mutations/packages/bulk_destroy.rb
index a0756d0c3f9..86f8de91e2b 100644
--- a/app/graphql/mutations/packages/bulk_destroy.rb
+++ b/app/graphql/mutations/packages/bulk_destroy.rb
@@ -16,11 +16,10 @@ module Mutations
def resolve(ids:)
raise_resource_not_available_error!(TOO_MANY_IDS_ERROR) if ids.size > MAX_PACKAGES
- ids = GitlabSchema.parse_gids(ids, expected_type: ::Packages::Package)
- .map(&:model_id)
+ model_ids = ids.map(&:model_id)
service = ::Packages::MarkPackagesForDestructionService.new(
- packages: packages_from(ids),
+ packages: packages_from(model_ids),
current_user: current_user
)
result = service.execute
diff --git a/app/graphql/mutations/releases/create.rb b/app/graphql/mutations/releases/create.rb
index ba1fa8d446c..15175aea9a5 100644
--- a/app/graphql/mutations/releases/create.rb
+++ b/app/graphql/mutations/releases/create.rb
@@ -30,7 +30,7 @@ module Mutations
required: false,
description: 'Description (also known as "release notes") of the release.'
- argument :released_at, Types::TimeType,
+ argument :released_at, Types::TimeType, # rubocop:disable Graphql/Descriptions
required: false,
description: 'Date and time for the release. Defaults to the current time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). Only provide this field if creating an upcoming or historical release.'
diff --git a/app/graphql/resolvers/ci/jobs_resolver.rb b/app/graphql/resolvers/ci/jobs_resolver.rb
index 91f29948ad0..31cc350f331 100644
--- a/app/graphql/resolvers/ci/jobs_resolver.rb
+++ b/app/graphql/resolvers/ci/jobs_resolver.rb
@@ -19,10 +19,15 @@ module Resolvers
required: false,
description: 'Filter jobs by retry-status.'
- def resolve(statuses: nil, security_report_types: [], retried: nil)
+ argument :when_executed, [::GraphQL::Types::String],
+ required: false,
+ description: 'Filter jobs by when they are executed.'
+
+ def resolve(statuses: nil, security_report_types: [], retried: nil, when_executed: nil)
jobs = init_collection(security_report_types)
jobs = jobs.with_status(statuses) if statuses.present?
jobs = jobs.retried if retried
+ jobs = jobs.with_when_executed(when_executed) if when_executed.present?
jobs = jobs.latest if retried == false
jobs
diff --git a/app/graphql/resolvers/ci/runner_groups_resolver.rb b/app/graphql/resolvers/ci/runner_groups_resolver.rb
index 3360e820bd2..c1d9bcbb9bb 100644
--- a/app/graphql/resolvers/ci/runner_groups_resolver.rb
+++ b/app/graphql/resolvers/ci/runner_groups_resolver.rb
@@ -6,7 +6,7 @@ module Resolvers
include Gitlab::Graphql::Authorize::AuthorizeResource
include ResolvesGroups
- type Types::GroupConnection, null: true
+ type 'Types::GroupConnection', null: true
authorize :read_runner
authorizes_object!
diff --git a/app/graphql/resolvers/concerns/board_item_filterable.rb b/app/graphql/resolvers/concerns/board_item_filterable.rb
index 9c0ada4f72c..035cdbbd282 100644
--- a/app/graphql/resolvers/concerns/board_item_filterable.rb
+++ b/app/graphql/resolvers/concerns/board_item_filterable.rb
@@ -22,6 +22,7 @@ module BoardItemFilterable
rewrite_param_name(filters[:or], :author_usernames, :author_username)
rewrite_param_name(filters[:or], :assignee_usernames, :assignee_username)
+ rewrite_param_name(filters[:or], :label_names, :label_name)
end
filters
diff --git a/app/graphql/resolvers/concerns/caching_array_resolver.rb b/app/graphql/resolvers/concerns/caching_array_resolver.rb
index e7555dcf42c..62649518142 100644
--- a/app/graphql/resolvers/concerns/caching_array_resolver.rb
+++ b/app/graphql/resolvers/concerns/caching_array_resolver.rb
@@ -63,6 +63,7 @@ module CachingArrayResolver
queries.in_groups_of(max_union_size, false).each do |group|
by_id = model_class
+ .select(all_fields, :union_member_idx)
.from_union(tag(group), remove_duplicates: false)
.preload(preload) # rubocop: disable CodeReuse/ActiveRecord
.group_by { |r| r[primary_key] }
diff --git a/app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb b/app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb
index c6e32be245d..2ea7a02bf15 100644
--- a/app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb
+++ b/app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb
@@ -20,7 +20,7 @@ module Issues
end
def preloads
- {
+ preload_hash = {
alert_management_alert: [:alert_management_alert],
assignees: [:assignees],
participants: Issue.participant_includes,
@@ -28,6 +28,9 @@ module Issues
customer_relations_contacts: { customer_relations_contacts: [:group] },
escalation_status: [:incident_management_issuable_escalation_status]
}
+ preload_hash[:type] = :work_item_type if Feature.enabled?(:issue_type_uses_work_item_types_table)
+
+ preload_hash
end
end
end
diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
index d56951bc821..c68e120ee24 100644
--- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb
+++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
@@ -34,7 +34,7 @@ module ResolvesMergeRequests
end
def unconditional_includes
- [:target_project]
+ [:target_project, :author]
end
def preloads
diff --git a/app/graphql/resolvers/concerns/search_arguments.rb b/app/graphql/resolvers/concerns/search_arguments.rb
index ccc012f2bf9..cc1a13fdf29 100644
--- a/app/graphql/resolvers/concerns/search_arguments.rb
+++ b/app/graphql/resolvers/concerns/search_arguments.rb
@@ -18,6 +18,7 @@ module SearchArguments
def ready?(**args)
validate_search_in_params!(args)
validate_anonymous_search_access!(args)
+ validate_search_rate_limit!(args)
super
end
@@ -39,6 +40,28 @@ module SearchArguments
'`search` should be present when including the `in` argument'
end
+ def validate_search_rate_limit!(args)
+ return if args[:search].blank? || context[:request].nil? || Feature.disabled?(:rate_limit_issuable_searches)
+
+ if current_user.present?
+ rate_limiter_key = :search_rate_limit
+ rate_limiter_scope = [current_user]
+ else
+ rate_limiter_key = :search_rate_limit_unauthenticated
+ rate_limiter_scope = [context[:request].ip]
+ end
+
+ if ::Gitlab::ApplicationRateLimiter.throttled_request?(
+ context[:request],
+ current_user,
+ rate_limiter_key,
+ scope: rate_limiter_scope
+ )
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable,
+ 'This endpoint has been requested with the search argument too many times. Try again later.'
+ end
+ end
+
def prepare_finder_params(args)
prepare_search_params(args)
end
diff --git a/app/graphql/resolvers/issues/base_resolver.rb b/app/graphql/resolvers/issues/base_resolver.rb
index 9a2c4572abb..fefd17d5e20 100644
--- a/app/graphql/resolvers/issues/base_resolver.rb
+++ b/app/graphql/resolvers/issues/base_resolver.rb
@@ -129,7 +129,8 @@ module Resolvers
params[:or] = params[:or].to_h if params[:or]
params[:iids] ||= [params.delete(:iid)].compact if params[:iid]
- prepare_author_username_params(params)
+ rewrite_param_name(params[:or], :author_usernames, :author_username)
+ rewrite_param_name(params[:or], :label_names, :label_name)
prepare_assignee_username_params(params)
prepare_release_tag_params(params)
@@ -143,20 +144,14 @@ module Resolvers
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?
+ rewrite_param_name(args, :assignee_usernames, :assignee_username)
+ rewrite_param_name(args[:or], :assignee_usernames, :assignee_username)
+ rewrite_param_name(args[:not], :assignee_usernames, :assignee_username)
+ end
- args[:not][:assignee_username] = args[:not].delete(:assignee_usernames)
+ def rewrite_param_name(params, old_name, new_name)
+ params[new_name] = params.delete(old_name) if params && params[old_name].present?
end
def mutually_exclusive_release_tag_args
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index 3e61ba755d8..24009bf7e18 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -5,6 +5,8 @@ module Resolvers
prepend ::Issues::LookAheadPreloads
include ::Issues::SortArguments
+ NON_FILTER_ARGUMENTS = %i[sort lookahead].freeze
+
argument :state, Types::IssuableStateEnum,
required: false,
description: 'Current state of this issue.'
@@ -17,6 +19,14 @@ module Resolvers
::Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute
end
+ def ready?(**args)
+ unless filter_provided?(args)
+ raise Gitlab::Graphql::Errors::ArgumentError, _('You must provide at least one filter argument for this query')
+ end
+
+ super
+ end
+
def resolve_with_lookahead(**args)
return unless Feature.enabled?(:root_level_issues_query)
@@ -32,5 +42,11 @@ module Resolvers
issues
end
end
+
+ private
+
+ def filter_provided?(args)
+ args.except(*NON_FILTER_ARGUMENTS).values.any?(&:present?)
+ end
end
end
diff --git a/app/graphql/resolvers/projects/branch_rules_resolver.rb b/app/graphql/resolvers/projects/branch_rules_resolver.rb
index e99d7ae4d5f..d1b39df602f 100644
--- a/app/graphql/resolvers/projects/branch_rules_resolver.rb
+++ b/app/graphql/resolvers/projects/branch_rules_resolver.rb
@@ -10,7 +10,15 @@ module Resolvers
alias_method :project, :object
def resolve_with_lookahead(**args)
- apply_lookahead(project.protected_branches)
+ protected_branches.map do |protected_branch|
+ ::Projects::BranchRule.new(project, protected_branch)
+ end
+ end
+
+ private
+
+ def protected_branches
+ apply_lookahead(project.protected_branches.sorted_by_name)
end
end
end
diff --git a/app/graphql/resolvers/timelog_resolver.rb b/app/graphql/resolvers/timelog_resolver.rb
index 52c4508003a..dc42a5f38c9 100644
--- a/app/graphql/resolvers/timelog_resolver.rb
+++ b/app/graphql/resolvers/timelog_resolver.rb
@@ -34,19 +34,23 @@ module Resolvers
required: false,
description: 'List timelogs for a user.'
+ argument :sort, Types::TimeTracking::TimelogSortEnum,
+ description: 'List timelogs in a particular order.',
+ required: false,
+ default_value: { field: 'spent_at', direction: :asc }
+
def resolve_with_lookahead(**args)
validate_args!(object, args)
- timelogs = object&.timelogs || Timelog.limit(GitlabSchema.default_max_page_size)
+ timelogs = object&.timelogs || Timelog.all
- if args.any?
- args = parse_datetime_args(args)
+ args = parse_datetime_args(args)
- timelogs = apply_user_filter(timelogs, args)
- timelogs = apply_project_filter(timelogs, args)
- timelogs = apply_time_filter(timelogs, args)
- timelogs = apply_group_filter(timelogs, args)
- end
+ timelogs = apply_user_filter(timelogs, args)
+ timelogs = apply_project_filter(timelogs, args)
+ timelogs = apply_time_filter(timelogs, args)
+ timelogs = apply_group_filter(timelogs, args)
+ timelogs = apply_sorting(timelogs, args)
apply_lookahead(timelogs)
end
@@ -60,7 +64,12 @@ module Resolvers
end
def validate_args!(object, args)
- if args.empty? && object.nil?
+ # sort is always provided because of its default value so we
+ # should check the remaining args to make sure at least one filter
+ # argument was provided
+ cleaned_args = args.except(:sort)
+
+ if cleaned_args.empty? && object.nil?
raise_argument_error('Provide at least one argument')
elsif args[:start_time] && args[:start_date]
raise_argument_error('Provide either a start date or time, but not both')
@@ -132,6 +141,15 @@ module Resolvers
timelogs
end
+ def apply_sorting(timelogs, args)
+ return timelogs unless args[:sort]
+
+ field = args[:sort][:field]
+ direction = args[:sort][:direction]
+
+ timelogs.sort_by_field(field, direction)
+ end
+
def raise_argument_error(message)
raise Gitlab::Graphql::Errors::ArgumentError, message
end
diff --git a/app/graphql/resolvers/work_items_resolver.rb b/app/graphql/resolvers/work_items_resolver.rb
index a3de875c196..83ed8c37250 100644
--- a/app/graphql/resolvers/work_items_resolver.rb
+++ b/app/graphql/resolvers/work_items_resolver.rb
@@ -55,7 +55,7 @@ module Resolvers
last_edited_by: :last_edited_by,
assignees: :assignees,
parent: :work_item_parent,
- children: { work_item_children: [:author, { project: :project_feature }] },
+ children: { work_item_children_by_created_at: [:author, { project: :project_feature }] },
labels: :labels,
milestone: :milestone
}
diff --git a/app/graphql/types/access_level_enum.rb b/app/graphql/types/access_level_enum.rb
index 299952e4685..d58e7230a8e 100644
--- a/app/graphql/types/access_level_enum.rb
+++ b/app/graphql/types/access_level_enum.rb
@@ -14,3 +14,5 @@ module Types
value 'OWNER', value: Gitlab::Access::OWNER, description: 'Owner access.'
end
end
+
+Types::AccessLevelEnum.prepend_mod_with('Types::AccessLevelEnum')
diff --git a/app/graphql/types/achievements/achievement_type.rb b/app/graphql/types/achievements/achievement_type.rb
new file mode 100644
index 00000000000..e2b9495c83d
--- /dev/null
+++ b/app/graphql/types/achievements/achievement_type.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module Types
+ module Achievements
+ class AchievementType < BaseObject
+ graphql_name 'Achievement'
+
+ authorize :read_achievement
+
+ field :id,
+ ::Types::GlobalIDType[::Achievements::Achievement],
+ null: false,
+ description: 'ID of the achievement.'
+
+ field :namespace,
+ ::Types::NamespaceType,
+ null: false,
+ description: 'Namespace of the achievement.'
+
+ field :name,
+ GraphQL::Types::String,
+ null: false,
+ description: 'Name of the achievement.'
+
+ field :avatar_url,
+ GraphQL::Types::String,
+ null: true,
+ description: 'URL to avatar of the achievement.'
+
+ field :description,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Description or notes for the achievement.'
+
+ field :revokeable,
+ GraphQL::Types::Boolean,
+ null: false,
+ description: 'Revokeability of the achievement.'
+
+ field :created_at,
+ Types::TimeType,
+ null: false,
+ description: 'Timestamp the achievement was created.'
+
+ field :updated_at,
+ Types::TimeType,
+ null: false,
+ description: 'Timestamp the achievement was last updated.'
+
+ def avatar_url
+ object.avatar_url(only_path: false)
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci/runner_countable_connection_type.rb b/app/graphql/types/ci/runner_countable_connection_type.rb
new file mode 100644
index 00000000000..f5c3a2c1f5f
--- /dev/null
+++ b/app/graphql/types/ci/runner_countable_connection_type.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class RunnerCountableConnectionType < ::Types::CountableConnectionType
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
+
+Types::Ci::RunnerCountableConnectionType.prepend_mod
diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb
index 5d34906f7b8..35339624e37 100644
--- a/app/graphql/types/ci/runner_type.rb
+++ b/app/graphql/types/ci/runner_type.rb
@@ -6,7 +6,7 @@ module Types
graphql_name 'CiRunner'
edge_type_class(RunnerWebUrlEdge)
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class(RunnerCountableConnectionType)
authorize :read_runner
present_using ::Ci::RunnerPresenter
@@ -38,10 +38,9 @@ module Types
field :executor_name, GraphQL::Types::String, null: true,
description: 'Executor last advertised by the runner.',
method: :executor_name
- field :groups, 'Types::GroupConnection',
- null: true,
- resolver: ::Resolvers::Ci::RunnerGroupsResolver,
- description: 'Groups the runner is associated with. For group runners only.'
+ field :groups, null: true,
+ resolver: ::Resolvers::Ci::RunnerGroupsResolver,
+ description: 'Groups the runner is associated with. For group runners only.'
field :id, ::Types::GlobalIDType[::Ci::Runner], null: false,
description: 'ID of the runner.'
field :ip_address, GraphQL::Types::String, null: true,
diff --git a/app/graphql/types/description_version_type.rb b/app/graphql/types/description_version_type.rb
new file mode 100644
index 00000000000..bee30597e4c
--- /dev/null
+++ b/app/graphql/types/description_version_type.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Types
+ class DescriptionVersionType < BaseObject
+ graphql_name 'DescriptionVersion'
+
+ authorize :read_issuable
+
+ field :id, ::Types::GlobalIDType[::DescriptionVersion],
+ null: false,
+ description: 'ID of the description version.'
+
+ field :description, GraphQL::Types::String,
+ null: true,
+ description: 'Content of the given description version.'
+ end
+end
+
+Types::DescriptionVersionType.prepend_mod
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index dd2ad26ce49..4948063610a 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -117,7 +117,6 @@ module Types
description: 'Collection of design images associated with this issue.'
field :type, Types::IssueTypeEnum, null: true,
- method: :issue_type,
description: 'Type of the issue.'
field :alert_management_alert,
@@ -198,6 +197,14 @@ module Types
def escalation_status
object.supports_escalation? ? object.escalation_status&.status_name : nil
end
+
+ def type
+ if Feature.enabled?(:issue_type_uses_work_item_types_table)
+ object.work_item_type.base_type
+ else
+ object.issue_type
+ end
+ end
end
end
diff --git a/app/graphql/types/issues/unioned_issue_filter_input_type.rb b/app/graphql/types/issues/unioned_issue_filter_input_type.rb
index 9c7261279c7..a9c5b3c24ce 100644
--- a/app/graphql/types/issues/unioned_issue_filter_input_type.rb
+++ b/app/graphql/types/issues/unioned_issue_filter_input_type.rb
@@ -11,6 +11,9 @@ module Types
argument :author_usernames, [GraphQL::Types::String],
required: false,
description: 'Filters issues that are authored by one of the given users.'
+ argument :label_names, [GraphQL::Types::String],
+ required: false,
+ description: 'Filters issues that have at least one of the given labels.'
end
end
end
diff --git a/app/graphql/types/member_access_level_enum.rb b/app/graphql/types/member_access_level_enum.rb
new file mode 100644
index 00000000000..8f89b882641
--- /dev/null
+++ b/app/graphql/types/member_access_level_enum.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ class MemberAccessLevelEnum < BaseEnum
+ graphql_name 'MemberAccessLevel'
+ description 'Access level of a group or project member'
+
+ value 'GUEST', value: Gitlab::Access::GUEST, description: 'Guest access.'
+ value 'REPORTER', value: Gitlab::Access::REPORTER, description: 'Reporter access.'
+ value 'DEVELOPER', value: Gitlab::Access::DEVELOPER, description: 'Developer access.'
+ value 'MAINTAINER', value: Gitlab::Access::MAINTAINER, description: 'Maintainer access.'
+ value 'OWNER', value: Gitlab::Access::OWNER, description: 'Owner access.'
+ end
+end
+
+Types::MemberAccessLevelEnum.prepend_mod_with('Types::MemberAccessLevelEnum')
diff --git a/app/graphql/types/member_interface.rb b/app/graphql/types/member_interface.rb
index edadbcddfb3..4c9ee6246a3 100644
--- a/app/graphql/types/member_interface.rb
+++ b/app/graphql/types/member_interface.rb
@@ -46,7 +46,7 @@ module Types
def merge_request_interaction(id: nil)
Gitlab::Graphql::Lazy.with_value(GitlabSchema.object_from_id(id, expected_class: ::MergeRequest)) do |merge_request|
- Users::MergeRequestInteraction.new(user: object.user, merge_request: merge_request) if merge_request
+ ::Users::MergeRequestInteraction.new(user: object.user, merge_request: merge_request) if merge_request
end
end
end
diff --git a/app/graphql/types/merge_requests/interacts_with_merge_request.rb b/app/graphql/types/merge_requests/interacts_with_merge_request.rb
index bef2d39dc5c..672a2a315d4 100644
--- a/app/graphql/types/merge_requests/interacts_with_merge_request.rb
+++ b/app/graphql/types/merge_requests/interacts_with_merge_request.rb
@@ -16,7 +16,7 @@ module Types
def merge_request_interaction(parent:, id: nil)
# need the connection parent if called from a connection node:
parent = parent.parent if parent.try(:field)&.connection?
- Users::MergeRequestInteraction.new(user: object, merge_request: parent)
+ ::Users::MergeRequestInteraction.new(user: object, merge_request: parent)
end
end
end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index b342e57804b..5a92ba754aa 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -6,6 +6,7 @@ module Types
include Gitlab::Graphql::MountMutation
+ mount_mutation Mutations::Achievements::Create
mount_mutation Mutations::Admin::SidekiqQueues::DeleteJobs
mount_mutation Mutations::AlertManagement::CreateAlertIssue
mount_mutation Mutations::AlertManagement::UpdateAlertStatus
@@ -66,6 +67,7 @@ module Types
mount_mutation Mutations::Issues::LinkAlerts
mount_mutation Mutations::Issues::UnlinkAlert
mount_mutation Mutations::Labels::Create
+ mount_mutation Mutations::Members::Groups::BulkUpdate
mount_mutation Mutations::MergeRequests::Accept
mount_mutation Mutations::MergeRequests::Create
mount_mutation Mutations::MergeRequests::Update
diff --git a/app/graphql/types/namespace/shared_runners_setting_enum.rb b/app/graphql/types/namespace/shared_runners_setting_enum.rb
index 4773e414aeb..fd067c9d803 100644
--- a/app/graphql/types/namespace/shared_runners_setting_enum.rb
+++ b/app/graphql/types/namespace/shared_runners_setting_enum.rb
@@ -4,10 +4,21 @@ module Types
class Namespace::SharedRunnersSettingEnum < BaseEnum
graphql_name 'SharedRunnersSetting'
- ::Namespace::SHARED_RUNNERS_SETTINGS.each do |type|
+ DEPRECATED_SETTINGS = [::Namespace::SR_DISABLED_WITH_OVERRIDE].freeze
+
+ ::Namespace::SHARED_RUNNERS_SETTINGS.excluding(DEPRECATED_SETTINGS).each do |type|
value type.upcase,
description: "Sharing of runners is #{type.tr('_', ' ')}.",
value: type
end
+
+ value ::Namespace::SR_DISABLED_WITH_OVERRIDE.upcase,
+ description: "Sharing of runners is disabled and overridable.",
+ value: ::Namespace::SR_DISABLED_WITH_OVERRIDE,
+ deprecated: {
+ reason: :renamed,
+ replacement: ::Namespace::SR_DISABLED_AND_OVERRIDABLE,
+ milestone: "17.0"
+ }
end
end
diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb
index 0f634e7c2d3..fc55ff512b6 100644
--- a/app/graphql/types/namespace_type.rb
+++ b/app/graphql/types/namespace_type.rb
@@ -63,6 +63,13 @@ module Types
description: "Timelog categories for the namespace.",
alpha: { milestone: '15.3' }
+ field :achievements,
+ Types::Achievements::AchievementType.connection_type,
+ null: true,
+ alpha: { milestone: '15.8' },
+ description: "Achievements for the namespace. " \
+ "Returns `null` if the `achievements` feature flag is disabled."
+
markdown_field :description_html, null: true
def timelog_categories
@@ -76,6 +83,10 @@ module Types
def root_storage_statistics
Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(object.id).find
end
+
+ def achievements
+ object.achievements if Feature.enabled?(:achievements, object)
+ end
end
end
diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb
index 05629ea9223..5055facb21b 100644
--- a/app/graphql/types/notes/note_type.rb
+++ b/app/graphql/types/notes/note_type.rb
@@ -11,54 +11,72 @@ module Types
implements(Types::ResolvableInterface)
- field :id, ::Types::GlobalIDType[::Note], null: false,
- description: 'ID of the note.'
+ field :id, ::Types::GlobalIDType[::Note],
+ null: false,
+ description: 'ID of the note.'
field :project, Types::ProjectType,
- null: true,
- description: 'Project associated with the note.'
+ null: true,
+ description: 'Project associated with the note.'
field :author, Types::UserType,
- null: false,
- description: 'User who wrote this note.'
+ null: false,
+ description: 'User who wrote this note.'
field :system, GraphQL::Types::Boolean,
- null: false,
- description: 'Indicates whether this note was created by the system or by a user.'
+ null: false,
+ description: 'Indicates whether this note was created by the system or by a user.'
field :system_note_icon_name,
- GraphQL::Types::String,
- null: true,
- description: 'Name of the icon corresponding to a system note.'
+ GraphQL::Types::String,
+ null: true,
+ description: 'Name of the icon corresponding to a system note.'
field :body, GraphQL::Types::String,
- null: false,
- method: :note,
- description: 'Content of the note.'
-
- field :confidential, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if this note is confidential.',
- method: :confidential?,
- deprecated: {
- reason: :renamed,
- replacement: 'internal',
- milestone: '15.5'
- }
-
- field :internal, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if this note is internal.',
- method: :confidential?
-
- field :created_at, Types::TimeType, null: false,
- description: 'Timestamp of the note creation.'
- field :discussion, Types::Notes::DiscussionType, null: true,
- description: 'Discussion this note is a part of.'
- field :position, Types::Notes::DiffPositionType, null: true,
- description: 'Position of this note on a diff.'
- field :updated_at, Types::TimeType, null: false,
- description: "Timestamp of the note's last activity."
+ null: false,
+ method: :note,
+ description: 'Content of the note.'
+
+ field :confidential, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates if this note is confidential.',
+ method: :confidential?,
+ deprecated: {
+ reason: :renamed,
+ replacement: 'internal',
+ milestone: '15.5'
+ }
+
+ field :internal, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates if this note is internal.',
+ method: :confidential?
+
+ field :created_at, Types::TimeType,
+ null: false,
+ description: 'Timestamp of the note creation.'
+ field :discussion, Types::Notes::DiscussionType,
+ null: true,
+ description: 'Discussion this note is a part of.'
+ field :position, Types::Notes::DiffPositionType,
+ null: true,
+ description: 'Position of this note on a diff.'
+ field :updated_at, Types::TimeType,
+ null: false,
+ description: "Timestamp of the note's last activity."
field :url, GraphQL::Types::String,
- null: true,
- description: 'URL to view this Note in the Web UI.'
+ null: true,
+ description: 'URL to view this Note in the Web UI.'
+
+ field :last_edited_at, Types::TimeType,
+ null: true,
+ description: 'Timestamp when note was last edited.'
+ field :last_edited_by, Types::UserType,
+ null: true,
+ description: 'User who last edited the note.'
+
+ field :system_note_metadata, Types::Notes::SystemNoteMetadataType,
+ null: true,
+ description: 'Metadata for the given note if it is a system note.'
markdown_field :body_html, null: true, method: :note
diff --git a/app/graphql/types/notes/noteable_interface.rb b/app/graphql/types/notes/noteable_interface.rb
index bd22f12d6f0..537084dff62 100644
--- a/app/graphql/types/notes/noteable_interface.rb
+++ b/app/graphql/types/notes/noteable_interface.rb
@@ -7,6 +7,7 @@ module Types
field :notes, Types::Notes::NoteType.connection_type, null: false, description: "All notes on this noteable."
field :discussions, Types::Notes::DiscussionType.connection_type, null: false, description: "All discussions on this noteable."
+ field :commenters, Types::UserType.connection_type, null: false, description: "All commenters on this noteable."
def self.resolve_type(object, context)
case object
@@ -24,6 +25,10 @@ module Types
raise "Unknown GraphQL type for #{object}"
end
end
+
+ def commenters
+ object.commenters(user: current_user)
+ end
end
end
end
diff --git a/app/graphql/types/notes/system_note_metadata_type.rb b/app/graphql/types/notes/system_note_metadata_type.rb
new file mode 100644
index 00000000000..b3dd7e037f9
--- /dev/null
+++ b/app/graphql/types/notes/system_note_metadata_type.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Types
+ module Notes
+ class SystemNoteMetadataType < BaseObject
+ graphql_name 'SystemNoteMetadata'
+
+ authorize :read_note
+
+ field :id, ::Types::GlobalIDType[::SystemNoteMetadata],
+ null: false,
+ description: 'Global ID of the specific system note metadata.'
+
+ field :action, GraphQL::Types::String,
+ null: true,
+ description: 'System note metadata action.'
+ field :description_version, ::Types::DescriptionVersionType,
+ null: true,
+ description: 'Version of the changed description.'
+ end
+ end
+end
diff --git a/app/graphql/types/projects/branch_rule_type.rb b/app/graphql/types/projects/branch_rule_type.rb
index 1afd2cc3fef..08b1203d4a3 100644
--- a/app/graphql/types/projects/branch_rule_type.rb
+++ b/app/graphql/types/projects/branch_rule_type.rb
@@ -5,7 +5,6 @@ module Types
class BranchRuleType < BaseObject
graphql_name 'BranchRule'
description 'List of branch rules for a project, grouped by branch name.'
- accepts ::ProtectedBranch
authorize :read_protected_branch
alias_method :branch_rule, :object
@@ -22,6 +21,12 @@ module Types
calls_gitaly: true,
description: "Check if this branch rule protects the project's default branch."
+ field :is_protected,
+ type: GraphQL::Types::Boolean,
+ null: false,
+ method: :protected?,
+ description: "Check if this branch rule protects access for the branch."
+
field :matching_branches_count,
type: GraphQL::Types::Int,
null: false,
@@ -30,9 +35,8 @@ module Types
field :branch_protection,
type: Types::BranchRules::BranchProtectionType,
- null: false,
- description: 'Branch protections configured for this branch rule.',
- method: :itself
+ null: true,
+ description: 'Branch protections configured for this branch rule.'
field :created_at,
Types::TimeType,
@@ -43,10 +47,6 @@ 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/query_type.rb b/app/graphql/types/query_type.rb
index 7263f792bae..990ba1fb7fc 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -7,56 +7,17 @@ module Types
# The design management context object needs to implement #issue
DesignManagementObject = Struct.new(:issue)
- field :project, Types::ProjectType,
- null: true,
- resolver: Resolvers::ProjectResolver,
- description: "Find a project."
-
- field :projects, Types::ProjectType.connection_type,
- null: true,
- resolver: Resolvers::ProjectsResolver,
- description: "Find projects visible to the current user."
-
- field :group, Types::GroupType,
- null: true,
- resolver: Resolvers::GroupResolver,
- description: "Find a group."
-
- field :current_user, Types::UserType,
- null: true,
- description: "Get information about current user."
-
- field :namespace, Types::NamespaceType,
- null: true,
- resolver: Resolvers::NamespaceResolver,
- description: "Find a namespace."
-
- field :metadata, Types::MetadataType,
- null: true,
- resolver: Resolvers::MetadataResolver,
- description: 'Metadata about GitLab.'
-
- field :query_complexity, Types::QueryComplexityType,
+ field :board_list, ::Types::BoardListType,
null: true,
- description: 'Information about the complexity of the GraphQL query.'
-
- field :snippets,
- Types::SnippetType.connection_type,
+ resolver: Resolvers::BoardListResolver
+ field :ci_application_settings, Types::Ci::ApplicationSettingType,
null: true,
- resolver: Resolvers::SnippetsResolver,
- description: 'Find Snippets visible to the current user.'
-
- field :design_management, Types::DesignManagementType,
- null: false,
- description: 'Fields related to design management.'
-
- field :milestone, ::Types::MilestoneType,
+ description: 'CI related settings that apply to the entire instance.'
+ field :ci_config, resolver: Resolvers::Ci::ConfigResolver, complexity: 126 # AUTHENTICATED_MAX_COMPLEXITY / 2 + 1
+ field :ci_variables,
+ Types::Ci::InstanceVariableType.connection_type,
null: true,
- extras: [:lookahead],
- description: 'Find a milestone.' do
- argument :id, ::Types::GlobalIDType[Milestone], required: true, description: 'Find a milestone by its ID.'
- end
-
+ description: "List of the instance's CI/CD variables."
field :container_repository, Types::ContainerRepositoryDetailsType,
null: true,
description: 'Find a container repository.' do
@@ -65,107 +26,116 @@ module Types
required: true,
description: 'Global ID of the container repository.'
end
-
- field :package,
- description: 'Find a package. This field can only be resolved for one query in any single request. Returns `null` if a package has no `default` status.',
- resolver: Resolvers::PackageDetailsResolver
-
- field :user, Types::UserType,
- null: true,
- description: 'Find a user.',
- resolver: Resolvers::UserResolver
-
- field :users, Types::UserType.connection_type,
+ field :current_user, Types::UserType,
null: true,
- description: 'Find users.',
- resolver: Resolvers::UsersResolver
-
+ description: "Get information about current user."
+ field :design_management, Types::DesignManagementType,
+ null: false,
+ description: 'Fields related to design management.'
field :echo, resolver: Resolvers::EchoResolver
-
- field :issues,
+ field :gitpod_enabled, GraphQL::Types::Boolean,
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.'
-
+ description: "Whether Gitpod is enabled in application settings."
+ field :group, Types::GroupType,
+ null: true,
+ resolver: Resolvers::GroupResolver,
+ description: "Find a group."
field :issue, Types::IssueType,
null: true,
description: 'Find an issue.' do
argument :id, ::Types::GlobalIDType[::Issue], required: true, description: 'Global ID of the issue.'
end
-
- field :work_item, Types::WorkItemType,
+ field :issues,
null: true,
- resolver: Resolvers::WorkItemResolver,
- alpha: { milestone: '15.1' },
- description: 'Find a work item.'
-
+ alpha: { milestone: '15.6' },
+ resolver: Resolvers::IssuesResolver,
+ description: 'Find issues visible to the current user.' \
+ ' At least one filter must be provided.' \
+ ' Returns `null` if the `root_level_issues_query` feature flag is disabled.'
+ field :jobs,
+ ::Types::Ci::JobType.connection_type,
+ null: true,
+ description: 'All jobs on this GitLab instance.',
+ resolver: ::Resolvers::Ci::AllJobsResolver
field :merge_request, Types::MergeRequestType,
null: true,
description: 'Find a merge request.' do
argument :id, ::Types::GlobalIDType[::MergeRequest], required: true, description: 'Global ID of the merge request.'
end
-
- field :usage_trends_measurements, Types::Admin::Analytics::UsageTrends::MeasurementType.connection_type,
+ field :metadata, Types::MetadataType,
null: true,
- description: 'Get statistics on the instance.',
- resolver: Resolvers::Admin::Analytics::UsageTrends::MeasurementsResolver
-
- field :ci_application_settings, Types::Ci::ApplicationSettingType,
+ resolver: Resolvers::MetadataResolver,
+ description: 'Metadata about GitLab.'
+ field :milestone, ::Types::MilestoneType,
null: true,
- description: 'CI related settings that apply to the entire instance.'
-
- field :runner_platforms, resolver: Resolvers::Ci::RunnerPlatformsResolver
- field :runner_setup, resolver: Resolvers::Ci::RunnerSetupResolver
-
+ extras: [:lookahead],
+ description: 'Find a milestone.' do
+ argument :id, ::Types::GlobalIDType[Milestone], required: true, description: 'Find a milestone by its ID.'
+ end
+ field :namespace, Types::NamespaceType,
+ null: true,
+ resolver: Resolvers::NamespaceResolver,
+ description: "Find a namespace."
+ field :package,
+ description: 'Find a package. This field can only be resolved for one query in any single request. Returns `null` if a package has no `default` status.',
+ resolver: Resolvers::PackageDetailsResolver
+ field :project, Types::ProjectType,
+ null: true,
+ resolver: Resolvers::ProjectResolver,
+ description: "Find a project."
+ field :projects, Types::ProjectType.connection_type,
+ null: true,
+ resolver: Resolvers::ProjectsResolver,
+ description: "Find projects visible to the current user."
+ field :query_complexity, Types::QueryComplexityType,
+ null: true,
+ description: 'Information about the complexity of the GraphQL query.'
field :runner, Types::Ci::RunnerType,
null: true,
resolver: Resolvers::Ci::RunnerResolver,
extras: [:lookahead],
description: "Find a runner."
-
+ field :runner_platforms, resolver: Resolvers::Ci::RunnerPlatformsResolver
+ field :runner_setup, resolver: Resolvers::Ci::RunnerSetupResolver
field :runners, Types::Ci::RunnerType.connection_type,
null: true,
resolver: Resolvers::Ci::RunnersResolver,
description: "Find runners visible to the current user."
-
- field :ci_variables,
- Types::Ci::InstanceVariableType.connection_type,
+ field :snippets,
+ Types::SnippetType.connection_type,
null: true,
- description: "List of the instance's CI/CD variables."
-
- field :ci_config, resolver: Resolvers::Ci::ConfigResolver, complexity: 126 # AUTHENTICATED_MAX_COMPLEXITY / 2 + 1
-
+ resolver: Resolvers::SnippetsResolver,
+ description: 'Find Snippets visible to the current user.'
field :timelogs, Types::TimelogType.connection_type,
null: true,
description: 'Find timelogs visible to the current user.',
extras: [:lookahead],
complexity: 5,
resolver: ::Resolvers::TimelogResolver
-
- field :board_list, ::Types::BoardListType,
- null: true,
- resolver: Resolvers::BoardListResolver
-
field :todo,
null: true,
resolver: Resolvers::TodoResolver
-
field :topics, Types::Projects::TopicType.connection_type,
null: true,
resolver: Resolvers::TopicsResolver,
description: "Find project topics."
-
- field :gitpod_enabled, GraphQL::Types::Boolean,
+ field :usage_trends_measurements, Types::Admin::Analytics::UsageTrends::MeasurementType.connection_type,
null: true,
- description: "Whether Gitpod is enabled in application settings."
-
- field :jobs,
- ::Types::Ci::JobType.connection_type,
+ description: 'Get statistics on the instance.',
+ resolver: Resolvers::Admin::Analytics::UsageTrends::MeasurementsResolver
+ field :user, Types::UserType,
null: true,
- description: 'All jobs on this GitLab instance.',
- resolver: ::Resolvers::Ci::AllJobsResolver
+ description: 'Find a user.',
+ resolver: Resolvers::UserResolver
+ field :users, Types::UserType.connection_type,
+ null: true,
+ description: 'Find users.',
+ resolver: Resolvers::UsersResolver
+ field :work_item, Types::WorkItemType,
+ null: true,
+ resolver: Resolvers::WorkItemResolver,
+ alpha: { milestone: '15.1' },
+ description: 'Find a work item.'
def design_management
DesignManagementObject.new(nil)
diff --git a/app/graphql/types/repository/blob_type.rb b/app/graphql/types/repository/blob_type.rb
index 8c90a8df611..c5d6e26e94b 100644
--- a/app/graphql/types/repository/blob_type.rb
+++ b/app/graphql/types/repository/blob_type.rb
@@ -44,11 +44,11 @@ module Types
field :fork_and_view_path, GraphQL::Types::String, null: true,
description: 'Web path to view this blob using a forked project.'
- field :size, GraphQL::Types::Int, null: true,
- description: 'Size (in bytes) of the blob.'
+ field :size, GraphQL::Types::BigInt, null: true,
+ description: 'Size (in bytes) of the blob.'
- field :raw_size, GraphQL::Types::Int, null: true,
- description: 'Size (in bytes) of the blob, or the blob target if stored externally.'
+ field :raw_size, GraphQL::Types::BigInt, null: true,
+ description: 'Size (in bytes) of the blob, or the blob target if stored externally.'
field :raw_blob, GraphQL::Types::String, null: true, method: :data,
description: 'Raw content of the blob.'
diff --git a/app/graphql/types/time_tracking/timelog_connection_type.rb b/app/graphql/types/time_tracking/timelog_connection_type.rb
new file mode 100644
index 00000000000..43e6955c2a3
--- /dev/null
+++ b/app/graphql/types/time_tracking/timelog_connection_type.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Types
+ module TimeTracking
+ # rubocop: disable Graphql/AuthorizeTypes
+ class TimelogConnectionType < CountableConnectionType
+ field :total_spent_time,
+ GraphQL::Types::Int,
+ null: false,
+ description: 'Total time spent in seconds.'
+
+ def total_spent_time
+ # rubocop: disable CodeReuse/ActiveRecord
+ relation = object.items
+
+ # sometimes relation is an Array
+ relation = relation.reorder(nil) if relation.respond_to?(:reorder)
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ relation.sum(:time_spent)
+ end
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/time_tracking/timelog_sort_enum.rb b/app/graphql/types/time_tracking/timelog_sort_enum.rb
new file mode 100644
index 00000000000..ad21c084d78
--- /dev/null
+++ b/app/graphql/types/time_tracking/timelog_sort_enum.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ module TimeTracking
+ class TimelogSortEnum < SortEnum
+ graphql_name 'TimelogSort'
+ description 'Values for sorting timelogs'
+
+ sortable_fields = ['Spent at', 'Time spent']
+
+ sortable_fields.each do |field|
+ value "#{field.upcase.tr(' ', '_')}_ASC",
+ value: { field: field.downcase.tr(' ', '_'), direction: :asc },
+ description: "#{field} by ascending order."
+ value "#{field.upcase.tr(' ', '_')}_DESC",
+ value: { field: field.downcase.tr(' ', '_'), direction: :desc },
+ description: "#{field} by descending order."
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/timelog_type.rb b/app/graphql/types/timelog_type.rb
index 3856e1aa3b3..3a060518cd9 100644
--- a/app/graphql/types/timelog_type.rb
+++ b/app/graphql/types/timelog_type.rb
@@ -4,6 +4,8 @@ module Types
class TimelogType < BaseObject
graphql_name 'Timelog'
+ connection_type_class(Types::TimeTracking::TimelogConnectionType)
+
authorize :read_issuable
expose_permissions Types::PermissionTypes::Timelog
diff --git a/app/graphql/types/todo_action_enum.rb b/app/graphql/types/todo_action_enum.rb
index 33e1c4e98a4..fda96796c0f 100644
--- a/app/graphql/types/todo_action_enum.rb
+++ b/app/graphql/types/todo_action_enum.rb
@@ -11,6 +11,6 @@ module Types
value 'directly_addressed', value: 7, description: 'User was directly addressed.'
value 'merge_train_removed', value: 8, description: 'Merge request authored by the user was removed from the merge train.'
value 'review_requested', value: 9, description: 'Review was requested from the user.'
- value 'member_access_requested', value: 10, description: 'Group access requested from the user.'
+ value 'member_access_requested', value: 10, description: 'Group or project access requested from the user.'
end
end
diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb
index 51046d09f90..a5bed3b9e19 100644
--- a/app/graphql/types/user_interface.rb
+++ b/app/graphql/types/user_interface.rb
@@ -42,10 +42,23 @@ module Types
null: true,
description: 'User email.', method: :public_email,
deprecated: { reason: :renamed, replacement: 'User.publicEmail', milestone: '13.7' }
+ field :emails,
+ type: Types::Users::EmailType.connection_type,
+ null: true,
+ description: "User's email addresses."
field :public_email,
type: GraphQL::Types::String,
null: true,
description: "User's public email."
+ field :commit_email,
+ type: GraphQL::Types::String,
+ null: true,
+ description: "User's default commit email.",
+ authorize: :read_user_email_address
+ field :namespace_commit_emails,
+ type: Types::Users::NamespaceCommitEmailType.connection_type,
+ null: true,
+ description: "User's custom namespace commit emails."
field :avatar_url,
type: GraphQL::Types::String,
null: true,
diff --git a/app/graphql/types/users/email_type.rb b/app/graphql/types/users/email_type.rb
new file mode 100644
index 00000000000..5e0b9563f57
--- /dev/null
+++ b/app/graphql/types/users/email_type.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Types
+ module Users
+ class EmailType < BaseObject
+ graphql_name 'Email'
+
+ authorize :read_user_email_address
+
+ field :id,
+ GraphQL::Types::ID,
+ null: false,
+ description: 'Internal ID of the email.'
+
+ field :email,
+ GraphQL::Types::String,
+ null: false,
+ description: 'Email address.'
+
+ field :confirmed_at,
+ Types::TimeType,
+ null: true,
+ description: 'Timestamp the email was confirmed.'
+
+ field :created_at,
+ Types::TimeType,
+ null: false,
+ description: 'Timestamp the email was created.'
+
+ field :updated_at,
+ Types::TimeType,
+ null: false,
+ description: 'Timestamp the email was last updated.'
+ end
+ end
+end
diff --git a/app/graphql/types/users/namespace_commit_email_type.rb b/app/graphql/types/users/namespace_commit_email_type.rb
new file mode 100644
index 00000000000..a64423c7169
--- /dev/null
+++ b/app/graphql/types/users/namespace_commit_email_type.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Types
+ module Users
+ class NamespaceCommitEmailType < BaseObject
+ graphql_name 'NamespaceCommitEmail'
+
+ authorize :read_user_email_address
+
+ field :id,
+ GraphQL::Types::ID,
+ null: false,
+ description: 'Internal ID of the namespace commit email.'
+
+ field :email,
+ Types::Users::EmailType,
+ null: false,
+ description: 'Email.'
+
+ field :namespace,
+ Types::NamespaceType,
+ null: false,
+ description: 'Namespace.'
+
+ field :created_at,
+ Types::TimeType,
+ null: false,
+ description: 'Timestamp the namespace commit email was created.'
+
+ field :updated_at,
+ Types::TimeType,
+ null: false,
+ description: 'Timestamp the namespace commit email was last updated.'
+ end
+ end
+end
diff --git a/app/graphql/types/work_items/widgets/description_type.rb b/app/graphql/types/work_items/widgets/description_type.rb
index 4861f7f46d8..92dca965b59 100644
--- a/app/graphql/types/work_items/widgets/description_type.rb
+++ b/app/graphql/types/work_items/widgets/description_type.rb
@@ -26,9 +26,7 @@ module Types
null: true,
description: 'User that made the last edit to the work item\'s description.'
- markdown_field :description_html, null: true do |resolved_object|
- resolved_object.work_item
- end
+ markdown_field :description_html, null: true, &:work_item
end
# rubocop:enable Graphql/AuthorizeTypes
end
diff --git a/app/helpers/admin/components_helper.rb b/app/helpers/admin/components_helper.rb
new file mode 100644
index 00000000000..577c12a0184
--- /dev/null
+++ b/app/helpers/admin/components_helper.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Admin
+ module ComponentsHelper
+ def database_versions
+ Gitlab::Database.database_base_models.transform_values do |base_model|
+ reflection = ::Gitlab::Database::Reflection.new(base_model)
+ {
+ adapter_name: reflection.human_adapter_name,
+ version: reflection.version
+ }
+ end.symbolize_keys
+ end
+ end
+end
diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb
index 9a323852996..ab743e27603 100644
--- a/app/helpers/appearances_helper.rb
+++ b/app/helpers/appearances_helper.rb
@@ -4,6 +4,10 @@ module AppearancesHelper
include MarkupHelper
include Gitlab::Utils::StrongMemoize
+ def appearance_short_name
+ Appearance.current&.pwa_short_name.presence || _('GitLab')
+ end
+
def brand_title
current_appearance&.title.presence || default_brand_title
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index c78563a9a5f..f4b34044e0e 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -20,23 +20,15 @@ module ApplicationHelper
def dispensable_render(...)
render(...)
rescue StandardError => e
- if Feature.enabled?(:dispensable_render)
- Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)
- nil
- else
- raise e
- end
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)
+ nil
end
def dispensable_render_if_exists(...)
render_if_exists(...)
rescue StandardError => e
- if Feature.enabled?(:dispensable_render)
- Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)
- nil
- else
- raise e
- end
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)
+ nil
end
def partial_exists?(partial)
@@ -183,6 +175,8 @@ module ApplicationHelper
#
# Returns an HTML-safe String
def time_ago_with_tooltip(time, placement: 'top', html_class: '', short_format: false)
+ return "" if time.nil?
+
css_classes = [short_format ? 'js-short-timeago' : 'js-timeago']
css_classes << html_class unless html_class.blank?
@@ -242,7 +236,7 @@ module ApplicationHelper
end
def instance_review_permitted?
- ::Gitlab::CurrentSettings.instance_review_permitted? && current_user&.admin?
+ ::Gitlab::CurrentSettings.instance_review_permitted? && current_user&.can_read_all_resources?
end
def static_objects_external_storage_enabled?
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 2b2ac262848..339938245a0 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -273,6 +273,7 @@ module ApplicationSettingsHelper
:housekeeping_full_repack_period,
:housekeeping_gc_period,
:housekeeping_incremental_repack_period,
+ :housekeeping_optimize_repository_period,
:html_emails_enabled,
:import_sources,
:in_product_marketing_emails_enabled,
@@ -447,7 +448,9 @@ module ApplicationSettingsHelper
:pipeline_limit_per_project_user_sha,
:invitation_flow_enforcement,
:can_create_group,
- :bulk_import_enabled
+ :bulk_import_enabled,
+ :allow_runner_registration_token,
+ :user_defaults_to_private_profile
].tap do |settings|
next if Gitlab.com?
diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb
index 9827f075e54..01d28ed3221 100644
--- a/app/helpers/broadcast_messages_helper.rb
+++ b/app/helpers/broadcast_messages_helper.rb
@@ -50,14 +50,6 @@ module BroadcastMessagesHelper
end
end
- def broadcast_type_options
- BroadcastMessage.broadcast_types.keys.map { |w| [w.humanize, w] }
- end
-
- def broadcast_theme_options
- BroadcastMessage.themes.keys
- end
-
def target_access_level_options
BroadcastMessage::ALLOWED_TARGET_ACCESS_LEVELS.map do |access_level|
[Gitlab::Access.human_access(access_level), access_level]
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index c47aef24367..64d6ba155cd 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -20,8 +20,8 @@ module ButtonHelper
#
# See http://clipboardjs.com/#usage
def clipboard_button(data = {})
- css_class = data[:class] || 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm'
- title = data[:title] || _('Copy')
+ css_class = data.delete(:class) || 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm'
+ title = data.delete(:title) || _('Copy')
button_text = data[:button_text] || nil
hide_tooltip = data[:hide_tooltip] || false
hide_button_icon = data[:hide_button_icon] || false
diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb
index 8df30ee1f0d..ac36c867baf 100644
--- a/app/helpers/ci/runners_helper.rb
+++ b/app/helpers/ci/runners_helper.rb
@@ -78,7 +78,7 @@ module Ci
parent_shared_runners_setting: group.parent&.shared_runners_setting,
runner_enabled_value: Namespace::SR_ENABLED,
runner_disabled_value: Namespace::SR_DISABLED_AND_UNOVERRIDABLE,
- runner_allow_override_value: Namespace::SR_DISABLED_WITH_OVERRIDE
+ runner_allow_override_value: Namespace::SR_DISABLED_AND_OVERRIDABLE
}
end
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index cad39854c0e..f6de405cecb 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -284,23 +284,38 @@ module EmailsHelper
end
def change_reviewer_notification_text(new_reviewers, previous_reviewers, html_tag = nil)
- new = new_reviewers.any? ? users_to_sentence(new_reviewers) : s_('ChangeReviewer|Unassigned')
- old = previous_reviewers.any? ? users_to_sentence(previous_reviewers) : nil
+ if new_reviewers.empty?
+ s_('ChangeReviewer|All reviewers were removed.')
+ else
+ added_reviewers = new_reviewers - previous_reviewers
+ removed_reviewers = previous_reviewers - new_reviewers
- if html_tag.present?
- new = content_tag(html_tag, new)
- old = content_tag(html_tag, old) if old.present?
- end
+ added_reviewers_template_text = added_reviewers.size > 1 ? "were added as reviewers.<br>" : "was added as a reviewer.<br>"
+ removed_reviewers_template_text = removed_reviewers.size > 1 ? "were removed from reviewers." : "was removed from reviewers."
- if old.present?
- s_('ChangeReviewer|Reviewer changed from %{old} to %{new}').html_safe % { old: old, new: new }
- else
- s_('ChangeReviewer|Reviewer changed to %{new}').html_safe % { new: new }
+ added = format_reviewers_string(added_reviewers, html_tag)
+ removed = format_reviewers_string(removed_reviewers, html_tag)
+
+ added_reviewers_text = added ? "#{added} #{added_reviewers_template_text}".html_safe : ''
+ removed_reviewers_text = removed ? "#{removed} #{removed_reviewers_template_text}".html_safe : ''
+ s_('ChangeReviewer|%{added_reviewers_text}%{removed_reviewers_text}').html_safe % { added_reviewers_text: added_reviewers_text, removed_reviewers_text: removed_reviewers_text }
end
end
private
+ def format_reviewers_string(reviewers, html_tag = nil)
+ return unless reviewers.any?
+
+ formatted_reviewers = users_to_sentence(reviewers)
+
+ if html_tag.present?
+ content_tag(html_tag, formatted_reviewers)
+ else
+ formatted_reviewers
+ end
+ end
+
def users_to_sentence(users)
sanitize_name(users.map(&:name).to_sentence)
end
diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb
index 0e64a98c9da..2b3700a9f21 100644
--- a/app/helpers/environment_helper.rb
+++ b/app/helpers/environment_helper.rb
@@ -58,8 +58,9 @@ module EnvironmentHelper
s_('Deployment|blocked')
end
- klass = "ci-status ci-#{status.dasherize}"
- text = "#{ci_icon_for_status(status)} #{status_text}".html_safe
+ ci_icon_utilities = "gl-display-inline-flex gl-align-items-center gl-line-height-0 gl-px-3 gl-py-2 gl-rounded-base"
+ klass = "ci-status ci-#{status.dasherize} #{ci_icon_utilities}"
+ text = "#{ci_icon_for_status(status)} <span class=\"gl-ml-2\">#{status_text}</span>".html_safe
if deployment.deployable
link_to(text, deployment_path(deployment), class: klass)
diff --git a/app/helpers/feature_flags_helper.rb b/app/helpers/feature_flags_helper.rb
index e12c6c605d2..3dde29dce91 100644
--- a/app/helpers/feature_flags_helper.rb
+++ b/app/helpers/feature_flags_helper.rb
@@ -18,7 +18,8 @@ module FeatureFlagsHelper
feature_flags_path: project_feature_flags_path(@project),
environments_endpoint: search_project_environments_path(@project, format: :json),
strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'),
- environments_scope_docs_path: help_page_path('ci/environments/index.md', anchor: 'scope-environments-with-specs')
+ environments_scope_docs_path: help_page_path('ci/environments/index.md',
+ anchor: 'limit-the-environment-scope-of-a-cicd-variable')
}
end
end
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index 963f0b7afc4..8f0d01c7d6d 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module FormHelper
- def form_errors(model, type: 'form', truncate: [])
+ def form_errors(model, type: 'form', truncate: [], custom_message: [])
errors = model.errors
return unless errors.any?
@@ -16,9 +16,15 @@ module FormHelper
messages = errors.map do |error|
attribute = error.attribute
- message = error.message
- message = html_escape_once(errors.full_message(attribute, message)).html_safe
+ message = errors.full_message(attribute, error.message)
+
+ # When error message is custom and does not follow the default pattern
+ # "<attribute name> <error message>" (e.g. "You have already reported this
+ # user"), use the message as-is
+ message = error.message if custom_message.include?(attribute)
+
+ message = html_escape_once(message).html_safe
message = tag.span(message, class: 'str-truncated-100') if truncate.include?(attribute)
message = append_help_page_link(message, error.options) if error.options[:help_page_url].present?
diff --git a/app/helpers/graph_helper.rb b/app/helpers/graph_helper.rb
index 788002f126d..ab72442857b 100644
--- a/app/helpers/graph_helper.rb
+++ b/app/helpers/graph_helper.rb
@@ -14,7 +14,7 @@ module GraphHelper
end
def parents_zip_spaces(parents, parent_spaces)
- ids = parents.map { |p| p.id }
+ ids = parents.map(&:id)
ids.zip(parent_spaces)
end
diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb
index 5034a4cb9b4..60011e31d43 100644
--- a/app/helpers/groups/group_members_helper.rb
+++ b/app/helpers/groups/group_members_helper.rb
@@ -13,7 +13,9 @@ module Groups::GroupMembersHelper
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_access_requests: can?(current_user, :admin_member_access_request, group)
+ can_manage_access_requests: can?(current_user, :admin_member_access_request, group),
+ group_name: group.name,
+ group_path: group.full_path
}
end
diff --git a/app/helpers/groups/observability_helper.rb b/app/helpers/groups/observability_helper.rb
index 26caac4ce7f..6cd6566cee1 100644
--- a/app/helpers/groups/observability_helper.rb
+++ b/app/helpers/groups/observability_helper.rb
@@ -5,15 +5,19 @@ module Groups
ACTION_TO_PATH = {
'dashboards' => {
path: '/',
- title: -> { _('Dashboards') }
+ title: -> { s_('Observability|Dashboards') }
},
'manage' => {
path: '/dashboards',
- title: -> { _('Manage Dashboards') }
+ title: -> { s_('Observability|Manage dashboards') }
},
'explore' => {
path: '/explore',
- title: -> { _('Explore') }
+ title: -> { s_('Observability|Explore telemetry data') }
+ },
+ 'datasources' => {
+ path: '/datasources',
+ title: -> { s_('Observability|Data sources') }
}
}.freeze
diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb
index 329bbb5ad82..81b881592d0 100644
--- a/app/helpers/import_helper.rb
+++ b/app/helpers/import_helper.rb
@@ -62,7 +62,7 @@ module ImportHelper
def import_configure_github_admin_message
github_integration_link = link_to 'GitHub integration', help_page_path('integration/github')
- if current_user.admin?
+ if current_user.can_admin_all_resources?
_('Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token.').html_safe % { github_integration_link: github_integration_link }
else
_('Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token.').html_safe % { github_integration_link: github_integration_link }
diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb
index 0650af33e37..5471109e6d5 100644
--- a/app/helpers/integrations_helper.rb
+++ b/app/helpers/integrations_helper.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
module IntegrationsHelper
+ # rubocop:disable Metrics/CyclomaticComplexity
def integration_event_title(event)
case event
when "push", "push_events"
@@ -27,8 +28,11 @@ module IntegrationsHelper
_("Deployment")
when "alert"
_("Alert")
+ when "incident"
+ _("Incident")
end
end
+ # rubocop:enable Metrics/CyclomaticComplexity
def integration_event_description(integration, event)
case integration
@@ -230,6 +234,7 @@ module IntegrationsHelper
end
end
+ # rubocop:disable Metrics/CyclomaticComplexity
def default_integration_event_description(event)
case event
when "push", "push_events"
@@ -256,8 +261,11 @@ module IntegrationsHelper
s_("ProjectService|Trigger event when a deployment starts or finishes.")
when "alert"
s_("ProjectService|Trigger event when a new, unique alert is recorded.")
+ when "incident"
+ s_("ProjectService|Trigger event when an incident is created.")
end
end
+ # rubocop:enable Metrics/CyclomaticComplexity
def trigger_events_for_integration(integration)
Integrations::EventSerializer.new(integration: integration).represent(integration.configurable_events).to_json
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 2b21d8c51e6..fb407aa7eed 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -207,14 +207,10 @@ module IssuablesHelper
def assigned_issuables_count(issuable_type)
case issuable_type
when :issues
- if Feature.enabled?(:limit_assigned_issues_count)
- ::Users::AssignedIssuesCountService.new(
- current_user: current_user,
- max_limit: User::MAX_LIMIT_FOR_ASSIGNEED_ISSUES_COUNT
- ).count
- else
- current_user.assigned_open_issues_count
- end
+ ::Users::AssignedIssuesCountService.new(
+ current_user: current_user,
+ max_limit: User::MAX_LIMIT_FOR_ASSIGNEED_ISSUES_COUNT
+ ).count
when :merge_requests
current_user.assigned_open_merge_requests_count
else
@@ -225,7 +221,7 @@ module IssuablesHelper
def assigned_open_issues_count_text
count = assigned_issuables_count(:issues)
- if Feature.enabled?(:limit_assigned_issues_count) && count > User::MAX_LIMIT_FOR_ASSIGNEED_ISSUES_COUNT - 1
+ if count > User::MAX_LIMIT_FOR_ASSIGNEED_ISSUES_COUNT - 1
"#{count - 1}+"
else
count.to_s
@@ -372,6 +368,14 @@ module IssuablesHelper
end
end
+ def hidden_issuable_icon(issuable)
+ title = format(_('This %{issuable} is hidden because its author has been banned'),
+ issuable: issuable.is_a?(Issue) ? _('issue') : _('merge request'))
+ content_tag(:span, class: 'has-tooltip', title: title) do
+ sprite_icon('spam', css_class: 'gl-vertical-align-text-bottom')
+ end
+ end
+
private
def sidebar_gutter_collapsed?
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 1d68dccc741..362f1e65425 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -35,20 +35,6 @@ module IssuesHelper
end
end
- def status_box_class(item)
- if item.try(:expired?)
- 'gl-bg-orange-500'
- elsif item.try(:merged?)
- 'badge-info'
- elsif item.closed?
- item.is_a?(MergeRequest) ? 'badge-danger' : 'gl-bg-red-500'
- elsif item.try(:upcoming?)
- 'gl-bg-gray-500'
- else
- item.is_a?(MergeRequest) ? 'badge-success' : 'gl-bg-green-500'
- end
- end
-
def issue_status_visibility(issue, status_box:)
case status_box
when :open
@@ -77,9 +63,7 @@ module IssuesHelper
def hidden_issue_icon(issue)
return unless issue_hidden?(issue)
- content_tag(:span, class: 'has-tooltip', title: _('This issue is hidden because its author has been banned')) do
- sprite_icon('spam', css_class: 'gl-vertical-align-text-bottom')
- end
+ hidden_issuable_icon(issue)
end
def award_user_list(awards, current_user, limit: 10)
@@ -197,7 +181,9 @@ module IssuesHelper
issue_type: issuable_display_type(issuable),
new_issue_path: new_project_issue_path(project, new_issuable_params),
project_path: project.full_path,
- report_abuse_path: new_abuse_report_path(user_id: issuable.author.id, ref_url: issue_url(issuable)),
+ report_abuse_path: add_category_abuse_reports_path,
+ reported_user_id: issuable.author.id,
+ reported_from_url: issue_url(issuable),
submit_as_spam_path: mark_as_spam_project_issue_path(project, issuable)
}
end
@@ -258,8 +244,12 @@ module IssuesHelper
def dashboard_issues_list_data(current_user)
{
+ autocomplete_award_emojis_path: autocomplete_award_emojis_path,
calendar_path: url_for(safe_params.merge(calendar_url_options)),
- empty_state_svg_path: image_path('illustrations/issue-dashboard_results-without-filter.svg'),
+ dashboard_labels_path: dashboard_labels_path(format: :json, include_ancestor_groups: true),
+ dashboard_milestones_path: dashboard_milestones_path(format: :json),
+ empty_state_with_filter_svg_path: image_path('illustrations/issues.svg'),
+ empty_state_without_filter_svg_path: image_path('illustrations/issue-dashboard_results-without-filter.svg'),
initial_sort: current_user&.user_preference&.issues_sort,
is_public_visibility_restricted:
Gitlab::CurrentSettings.restricted_visibility_levels&.include?(Gitlab::VisibilityLevel::PUBLIC).to_s,
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index ed9129ff78b..4a5720e757d 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -63,17 +63,8 @@ module MarkupHelper
md = markdown_field(object, attribute, options.merge(post_process: false))
return unless md.present?
- includes_code = false
-
tags = %w(a gl-emoji b strong i em pre code p span)
- if is_todo
- fragment = Nokogiri::HTML.fragment(md)
- includes_code = fragment.css('code').any?
-
- md = fragment
- end
-
context = markdown_field_render_context(object, attribute, options)
context.reverse_merge!(truncate_visible_max_chars: max_chars || md.length)
@@ -89,12 +80,6 @@ module MarkupHelper
)
)
- # Extra span with relative positioning relative due to system font being behind
- # background color when username is first word of mention
- if is_todo && !includes_code
- text = "<span class=\"gl-relative\">\"</span>#{text}<span class=\"gl-relative\">\"</span>"
- end
-
# since <img> tags are stripped, this can leave empty <a> tags hanging around
# (as our markdown wraps images in links)
strip_empty_link_tags(text).html_safe
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 1d7d812dc5d..ec395baef9e 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -276,6 +276,12 @@ module MergeRequestsHelper
data
end
+
+ def hidden_merge_request_icon(merge_request)
+ return unless merge_request.hidden?
+
+ hidden_issuable_icon(merge_request)
+ end
end
MergeRequestsHelper.prepend_mod_with('MergeRequestsHelper')
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index bf3b132e33a..d0421cd5184 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -65,6 +65,10 @@ module NavHelper
%w(dev_ops_report usage_trends)
end
+ def show_super_sidebar?
+ Feature.enabled?(:super_sidebar_nav, current_user) && current_user&.use_new_navigation
+ end
+
private
def get_header_links
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index f2b7c0064e4..88e68a52199 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -17,8 +17,11 @@ module PreferencesHelper
dashboards -= excluded_dashboard_choices
dashboards.map do |key|
- # Use `fetch` so `KeyError` gets raised when a key is missing
- [localized_dashboard_choices.fetch(key), key]
+ {
+ # Use `fetch` so `KeyError` gets raised when a key is missing
+ text: localized_dashboard_choices.fetch(key),
+ value: key
+ }
end
end
@@ -99,10 +102,12 @@ module PreferencesHelper
end
def language_choices
- options_for_select(
- selectable_locales_with_translation_level(Gitlab::I18n::MINIMUM_TRANSLATION_LEVEL).sort,
- current_user.preferred_language
- )
+ selectable_locales_with_translation_level(Gitlab::I18n::MINIMUM_TRANSLATION_LEVEL).sort.map do |lang, key|
+ {
+ text: lang,
+ value: key
+ }
+ end
end
def default_preferred_language_choices
diff --git a/app/helpers/projects/ml/experiments_helper.rb b/app/helpers/projects/ml/experiments_helper.rb
index a67484e3d2f..b9a219b3021 100644
--- a/app/helpers/projects/ml/experiments_helper.rb
+++ b/app/helpers/projects/ml/experiments_helper.rb
@@ -11,7 +11,10 @@ module Projects
**candidate.params.to_h { |p| [p.name, p.value] },
**candidate.latest_metrics.to_h { |m| [m.name, number_with_precision(m.value, precision: 4)] },
artifact: link_to_artifact(candidate),
- details: link_to_details(candidate)
+ details: link_to_details(candidate),
+ name: candidate.name,
+ created_at: candidate.created_at,
+ user: user_info(candidate)
}
end
@@ -32,7 +35,8 @@ module Projects
experiment_name: candidate.experiment.name,
path_to_experiment: link_to_experiment(candidate),
status: candidate.status
- }
+ },
+ metadata: candidate.metadata
}
Gitlab::Json.generate(data)
@@ -45,11 +49,11 @@ module Projects
return unless artifact.present?
- project_package_path(candidate.experiment.project, artifact)
+ project_package_path(candidate.project, artifact)
end
def link_to_details(candidate)
- project_ml_candidate_path(candidate.experiment.project, candidate.iid)
+ project_ml_candidate_path(candidate.project, candidate.iid)
end
def link_to_experiment(candidate)
@@ -57,6 +61,17 @@ module Projects
project_ml_experiment_path(experiment.project, experiment.iid)
end
+
+ def user_info(candidate)
+ user = candidate.user
+
+ return unless user.present?
+
+ {
+ username: user.username,
+ path: user_path(user)
+ }
+ end
end
end
end
diff --git a/app/helpers/projects/project_members_helper.rb b/app/helpers/projects/project_members_helper.rb
index 6026124abb9..634f6e8ba59 100644
--- a/app/helpers/projects/project_members_helper.rb
+++ b/app/helpers/projects/project_members_helper.rb
@@ -9,7 +9,9 @@ module Projects::ProjectMembersHelper
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_access_requests: Ability.allowed?(current_user, :admin_member_access_request, project)
+ can_manage_access_requests: Ability.allowed?(current_user, :admin_member_access_request, project),
+ group_name: project.group&.name,
+ group_path: project.group&.full_path
}.to_json
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 682febe9dc9..507e05b9967 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -479,32 +479,6 @@ module ProjectsHelper
format_cached_count(1000, number)
end
- def fork_divergence_message(counts)
- messages = []
-
- if counts[:behind].nil? || counts[:ahead].nil?
- return s_('ForksDivergence|Fork has diverged from upstream repository')
- end
-
- if counts[:behind] > 0
- messages << s_("ForksDivergence|%{behind} %{commit_word} behind") % {
- behind: counts[:behind], commit_word: n_('commit', 'commits', counts[:behind])
- }
- end
-
- if counts[:ahead] > 0
- messages << s_("ForksDivergence|%{ahead} %{commit_word} ahead of") % {
- ahead: counts[:ahead], commit_word: n_('commit', 'commits', counts[:ahead])
- }
- end
-
- if messages.blank?
- s_('ForksDivergence|Up to date with upstream repository')
- else
- s_("ForksDivergence|%{messages} upstream repository") % { messages: messages.join(', ') }
- end
- end
-
private
def localized_access_names
@@ -520,7 +494,7 @@ module ProjectsHelper
end
def configure_oauth_import_message(provider, help_url)
- str = if current_user.admin?
+ str = if current_user.can_admin_all_resources?
'ImportProjects|To enable importing projects from %{provider}, as administrator you need to configure %{link_start}OAuth integration%{link_end}'
else
'ImportProjects|To enable importing projects from %{provider}, ask your GitLab administrator to configure %{link_start}OAuth integration%{link_end}'
@@ -658,7 +632,7 @@ module ProjectsHelper
end
def restricted_levels
- return [] if current_user.admin?
+ return [] if current_user.can_admin_all_resources?
Gitlab::CurrentSettings.restricted_visibility_levels || []
end
diff --git a/app/helpers/protected_branches_helper.rb b/app/helpers/protected_branches_helper.rb
new file mode 100644
index 00000000000..07b07bfd33c
--- /dev/null
+++ b/app/helpers/protected_branches_helper.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module ProtectedBranchesHelper
+ def protected_branch_can_admin_entity?(protected_branch_entity)
+ if protected_branch_entity.is_a?(Group)
+ can?(current_user, :admin_group, protected_branch_entity)
+ else
+ can?(current_user, :admin_project, protected_branch_entity)
+ end
+ end
+
+ def protected_branch_path_by_entity(protected_branch, protected_branch_entity)
+ if protected_branch_entity.is_a?(Group)
+ group_protected_branch_path(protected_branch_entity, protected_branch)
+ else
+ project_protected_branch_path(protected_branch_entity, protected_branch)
+ end
+ end
+end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index e03365ad5f1..ca5436ff019 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -49,7 +49,7 @@ module SearchHelper
search_pattern = Regexp.new(Regexp.escape(term), "i")
generic_results = project_autocomplete + default_autocomplete + help_autocomplete
- generic_results.concat(default_autocomplete_admin) if current_user.admin?
+ generic_results.concat(default_autocomplete_admin) if current_user.can_read_all_resources?
generic_results.select { |result| result[:label] =~ search_pattern }
end
diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb
index 99da9a7af6c..7ee40c28bad 100644
--- a/app/helpers/selects_helper.rb
+++ b/app/helpers/selects_helper.rb
@@ -1,20 +1,6 @@
# frozen_string_literal: true
module SelectsHelper
- def groups_select_tag(id, opts = {})
- classes = Array.wrap(opts[:class])
- classes << 'ajax-groups-select'
-
- # EE requires this line to be present, but there is no easy way of injecting
- # this into EE without causing merge conflicts. Given this line is very
- # simple and not really EE specific on its own, we just include it in CE.
- classes << 'multiselect' if opts[:multiple]
-
- opts[:class] = classes.join(' ')
-
- select2_tag(id, opts)
- end
-
def project_select_tag(id, opts = {})
opts[:class] = [*opts[:class], 'ajax-project-select'].join(' ')
diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb
index cbee02a28c0..f6257c92f3f 100644
--- a/app/helpers/sidebars_helper.rb
+++ b/app/helpers/sidebars_helper.rb
@@ -31,6 +31,18 @@ module SidebarsHelper
Sidebars::Groups::Context.new(**context_data)
end
+ def super_sidebar_context(user)
+ {
+ name: user.name,
+ username: user.username,
+ avatar_url: user.avatar_url,
+ assigned_open_issues_count: user.assigned_open_issues_count,
+ assigned_open_merge_requests_count: user.assigned_open_merge_requests_count,
+ todos_pending_count: user.todos_pending_count,
+ issues_dashboard_path: issues_dashboard_path(assignee_username: user.username)
+ }
+ end
+
private
def sidebar_attributes_for_object(object)
diff --git a/app/helpers/timeboxes_helper.rb b/app/helpers/timeboxes_helper.rb
index 307f03e0d64..66c9011fbcc 100644
--- a/app/helpers/timeboxes_helper.rb
+++ b/app/helpers/timeboxes_helper.rb
@@ -8,7 +8,7 @@ module TimeboxesHelper
if milestone.closed?
_('Closed')
elsif milestone.expired?
- _('Past due')
+ _('Expired')
elsif milestone.upcoming?
_('Upcoming')
else
@@ -16,6 +16,18 @@ module TimeboxesHelper
end
end
+ def milestone_badge_variant(milestone)
+ if milestone.closed?
+ :danger
+ elsif milestone.expired?
+ :warning
+ elsif milestone.upcoming?
+ :neutral
+ else
+ :success
+ end
+ end
+
def milestones_filter_path(opts = {})
if @project
project_milestones_path(@project, opts)
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index d7c4540544b..daaf86e55a0 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -28,7 +28,7 @@ module TodosHelper
when Todo::UNMERGEABLE then s_('Todos|Could not merge')
when Todo::MERGE_TRAIN_REMOVED then s_("Todos|Removed from Merge Train")
when Todo::MEMBER_ACCESS_REQUESTED then format(
- s_("Todos|has requested access to group %{which}"), which: _(todo.target.name)
+ s_("Todos|has requested access to %{what} %{which}"), what: _(todo.member_access_type), which: _(todo.target.name)
)
end
end
@@ -99,7 +99,7 @@ module TodosHelper
path_options[:only_path] = true
Gitlab::UrlBuilder.build(todo.target, **path_options)
elsif todo.member_access_requested?
- todo.access_request_url
+ todo.access_request_url(only_path: true)
else
path = [todo.resource_parent, todo.target]
diff --git a/app/helpers/url_helper.rb b/app/helpers/url_helper.rb
new file mode 100644
index 00000000000..9083ca4e8dd
--- /dev/null
+++ b/app/helpers/url_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module UrlHelper
+ def escaped_url(url)
+ return unless url
+
+ Addressable::URI.escape(url)
+ rescue Addressable::URI::InvalidURIError
+ nil
+ end
+end
diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb
index a9fd219bbac..6ab78b58457 100644
--- a/app/helpers/users/callouts_helper.rb
+++ b/app/helpers/users/callouts_helper.rb
@@ -49,7 +49,7 @@ module Users
def show_registration_enabled_user_callout?
!Gitlab.com? &&
- current_user&.admin? &&
+ current_user&.can_admin_all_resources? &&
signup_enabled? &&
!user_dismissed?(REGISTRATION_ENABLED_CALLOUT) &&
REGISTRATION_ENABLED_CALLOUT_ALLOWED_CONTROLLER_PATHS.any? { |path| controller.controller_path.match?(path) }
@@ -59,7 +59,7 @@ module Users
end
def show_security_newsletter_user_callout?
- current_user&.admin? &&
+ current_user&.can_admin_all_resources? &&
!user_dismissed?(SECURITY_NEWSLETTER_CALLOUT)
end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index 4f345fdeb9c..a382b5e7b74 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -93,7 +93,7 @@ module UsersHelper
def user_badges_in_admin_section(user)
[].tap do |badges|
badges << blocked_user_badge(user) if user.blocked?
- badges << { text: s_('AdminUsers|Admin'), variant: 'success' } if user.admin?
+ badges << { text: s_('AdminUsers|Admin'), variant: 'success' } if user.admin? # rubocop:disable Cop/UserAdmin
badges << { text: s_('AdminUsers|Bot'), variant: 'muted' } if user.bot?
badges << { text: s_('AdminUsers|External'), variant: 'secondary' } if user.external?
badges << { text: s_("AdminUsers|It's you!"), variant: 'muted' } if current_user == user
diff --git a/app/helpers/version_check_helper.rb b/app/helpers/version_check_helper.rb
index 0bb92dfd118..4bd89a3d4e2 100644
--- a/app/helpers/version_check_helper.rb
+++ b/app/helpers/version_check_helper.rb
@@ -18,7 +18,7 @@ module VersionCheckHelper
strong_memoize_attr :gitlab_version_check
def show_security_patch_upgrade_alert?
- return false unless show_version_check? && gitlab_version_check
+ return false unless Feature.enabled?(:critical_security_alert) && show_version_check? && gitlab_version_check
gitlab_version_check['severity'] === SECURITY_ALERT_SEVERITY
end
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index 1baeeda0ef7..5ed341ee5e5 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -39,7 +39,7 @@ module VisibilityLevelHelper
end
def restricted_visibility_levels(show_all = false)
- return [] if current_user.admin? && !show_all
+ return [] if current_user.can_admin_all_resources? && !show_all
Gitlab::CurrentSettings.restricted_visibility_levels || []
end
diff --git a/app/helpers/work_items_helper.rb b/app/helpers/work_items_helper.rb
index 2c61fc20ca8..efa9a2bd463 100644
--- a/app/helpers/work_items_helper.rb
+++ b/app/helpers/work_items_helper.rb
@@ -4,7 +4,9 @@ module WorkItemsHelper
def work_items_index_data(project)
{
full_path: project.full_path,
- issues_list_path: project_issues_path(project)
+ issues_list_path: project_issues_path(project),
+ register_path: new_user_registration_path(redirect_to_referer: 'yes'),
+ sign_in_path: new_session_path(:user, redirect_to_referer: 'yes')
}
end
end
diff --git a/app/mailers/devise_mailer.rb b/app/mailers/devise_mailer.rb
index 61a23520d54..7129e577cb8 100644
--- a/app/mailers/devise_mailer.rb
+++ b/app/mailers/devise_mailer.rb
@@ -17,6 +17,11 @@ class DeviseMailer < Devise::Mailer
devise_mail(record, :user_admin_approval, opts)
end
+ def reset_password_instructions(record, token, opts = {})
+ headers['X-Mailgun-Suppressions-Bypass'] = 'true'
+ super
+ end
+
protected
def subject_for(key)
diff --git a/app/mailers/emails/imports.rb b/app/mailers/emails/imports.rb
new file mode 100644
index 00000000000..d3e8b90d686
--- /dev/null
+++ b/app/mailers/emails/imports.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Emails
+ module Imports
+ def github_gists_import_errors_email(user_id, errors)
+ @errors = errors
+ user = User.find(user_id)
+
+ email_with_layout(
+ to: user.notification_email_or_default,
+ subject: subject(s_('GithubImporter|GitHub Gists import finished with errors'))
+ )
+ end
+ end
+end
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index ede6007e0e2..5b1750400d8 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -83,14 +83,15 @@ module Emails
end
end
- def access_token_expired_email(user)
+ def access_token_expired_email(user, token_names = [])
return unless user && user.active?
@user = user
+ @token_names = token_names
@target_url = profile_personal_access_tokens_url
Gitlab::I18n.with_locale(@user.preferred_language) do
- mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your personal access token has expired")))
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your personal access tokens have expired")))
end
end
diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb
index 66eb2c646a9..2953ec6cbe5 100644
--- a/app/mailers/emails/service_desk.rb
+++ b/app/mailers/emails/service_desk.rb
@@ -5,6 +5,8 @@ module Emails
extend ActiveSupport::Concern
include MarkupHelper
+ EMAIL_ATTACHMENTS_SIZE_LIMIT = 10.megabytes.freeze
+
included do
layout 'service_desk', only: [:service_desk_thank_you_email, :service_desk_new_note_email]
end
@@ -28,6 +30,7 @@ module Emails
setup_service_desk_mail(issue_id)
email_sender = sender(@note.author_id)
+ add_uploads_as_attachments if Feature.enabled?(:service_desk_new_note_email_native_attachments, @note.project)
options = service_desk_options(email_sender, 'new_note', recipient)
.merge(subject: subject_base)
@@ -60,7 +63,8 @@ module Emails
template = Gitlab::Template::ServiceDeskTemplate.find(email_type, @project)
text = substitute_template_replacements(template.content)
- context = { project: @project, pipeline: :email }
+ context = { project: @project, pipeline: :service_desk_email, uploads_as_attachments: @uploads_as_attachments }
+
context[:author] = @note.author if email_type == 'new_note'
markdown(text, context)
@@ -90,5 +94,32 @@ module Emails
def subject_base
"#{@issue.title} (##{@issue.iid})"
end
+
+ def add_uploads_as_attachments
+ uploaders = find_uploaders_for(@note)
+ return unless uploaders.present?
+ return if uploaders.sum(&:size) > EMAIL_ATTACHMENTS_SIZE_LIMIT
+
+ @uploads_as_attachments = []
+ uploaders.each do |uploader|
+ attachments[uploader.filename] = uploader.read
+ @uploads_as_attachments << "#{uploader.secret}/#{uploader.filename}"
+ rescue StandardError => e
+ Gitlab::ErrorTracking.track_exception(e, project_id: @note.project.id)
+ end
+ end
+
+ def find_uploaders_for(note)
+ uploads = FileUploader::MARKDOWN_PATTERN.scan(note.note)
+ return unless uploads.present?
+
+ project = note.project
+ uploads.map do |secret, file_name|
+ UploaderFinder.new(project, secret, file_name).execute
+ end
+ rescue StandardError => e
+ Gitlab::ErrorTracking.track_exception(e, project_id: note.project.id)
+ nil
+ end
end
end
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index 5a3fc70832c..31726563662 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -24,6 +24,7 @@ class Notify < ApplicationMailer
include Emails::InProductMarketing
include Emails::AdminNotification
include Emails::IdentityVerification
+ include Emails::Imports
helper TimeboxesHelper
helper MergeRequestsHelper
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index 7d7e01950c8..7ed594bf571 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -225,6 +225,10 @@ class NotifyPreview < ActionMailer::Preview
Notify.project_was_moved_email(project.id, user.id, "gitlab/gitlab").message
end
+ def github_gists_import_errors_email
+ Notify.github_gists_import_errors_email(user.id, { '12345' => 'Snippet maximum file count exceeded', '67890' => 'error message 2' }).message
+ end
+
private
def project
diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb
index f1f22d94061..ee0c23ef31e 100644
--- a/app/models/abuse_report.rb
+++ b/app/models/abuse_report.rb
@@ -12,14 +12,49 @@ class AbuseReport < ApplicationRecord
validates :reporter, presence: true
validates :user, presence: true
validates :message, presence: true
- validates :user_id, uniqueness: { message: 'has already been reported' }
+ validates :category, presence: true
+ validates :user_id,
+ uniqueness: {
+ scope: [:reporter_id, :category],
+ message: ->(object, data) do
+ _('You have already reported this user')
+ end
+ }
+
+ validates :reported_from_url,
+ allow_blank: true,
+ length: { maximum: 512 },
+ addressable_url: {
+ dns_rebind_protection: true,
+ blocked_message: 'is an invalid URL. You can try reporting the abuse again, ' \
+ 'or contact a GitLab administrator for help.'
+ }
scope :by_user, ->(user) { where(user_id: user) }
scope :with_users, -> { includes(:reporter, :user) }
+ enum category: {
+ spam: 1,
+ offensive: 2,
+ phishing: 3,
+ crypto: 4,
+ credentials: 5,
+ copyright: 6,
+ malware: 7,
+ other: 8
+ }
+
# For CacheMarkdownField
alias_method :author, :reporter
+ HUMANIZED_ATTRIBUTES = {
+ reported_from_url: "Reported from"
+ }.freeze
+
+ def self.human_attribute_name(attr, options = {})
+ HUMANIZED_ATTRIBUTES[attr.to_sym] || super
+ end
+
def remove_user(deleted_by:)
user.delete_async(deleted_by: deleted_by, params: { hard_delete: true })
end
diff --git a/app/models/achievements/achievement.rb b/app/models/achievements/achievement.rb
index 904961491b5..a436e32b35b 100644
--- a/app/models/achievements/achievement.rb
+++ b/app/models/achievements/achievement.rb
@@ -7,6 +7,9 @@ module Achievements
belongs_to :namespace, inverse_of: :achievements, optional: false
+ has_many :user_achievements, inverse_of: :achievement
+ has_many :users, through: :user_achievements, inverse_of: :achievements
+
strip_attributes! :name, :description
validates :name,
diff --git a/app/models/achievements/user_achievement.rb b/app/models/achievements/user_achievement.rb
new file mode 100644
index 00000000000..885ec660cc9
--- /dev/null
+++ b/app/models/achievements/user_achievement.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Achievements
+ class UserAchievement < ApplicationRecord
+ belongs_to :achievement, inverse_of: :user_achievements, optional: false
+ belongs_to :user, inverse_of: :user_achievements, optional: false
+
+ belongs_to :awarded_by_user,
+ class_name: 'User',
+ inverse_of: :awarded_user_achievements,
+ optional: true
+ belongs_to :revoked_by_user,
+ class_name: 'User',
+ inverse_of: :revoked_user_achievements,
+ optional: true
+ end
+end
diff --git a/app/models/analytics/cycle_analytics/aggregation.rb b/app/models/analytics/cycle_analytics/aggregation.rb
index a888422a6b4..b432955ad88 100644
--- a/app/models/analytics/cycle_analytics/aggregation.rb
+++ b/app/models/analytics/cycle_analytics/aggregation.rb
@@ -2,8 +2,7 @@
class Analytics::CycleAnalytics::Aggregation < ApplicationRecord
include FromUnion
-
- belongs_to :group, optional: false
+ include Analytics::CycleAnalytics::Parentable
validates :incremental_runtimes_in_seconds, :incremental_processed_records, :full_runtimes_in_seconds, :full_processed_records, presence: true, length: { maximum: 10 }, allow_blank: true
@@ -58,7 +57,10 @@ class Analytics::CycleAnalytics::Aggregation < ApplicationRecord
estimation < 1 ? nil : estimation.from_now
end
- def self.safe_create_for_group(group)
+ def self.safe_create_for_namespace(group_or_project_namespace)
+ # Namespaces::ProjectNamespace has no root_ancestor
+ # Related: https://gitlab.com/gitlab-org/gitlab/-/issues/386124
+ group = group_or_project_namespace.is_a?(Group) ? group_or_project_namespace : group_or_project_namespace.parent
top_level_group = group.root_ancestor
aggregation = find_by(group_id: top_level_group.id)
return aggregation if aggregation.present?
diff --git a/app/models/analytics/cycle_analytics/project_stage.rb b/app/models/analytics/cycle_analytics/project_stage.rb
index 8d3a032812e..8a80514333f 100644
--- a/app/models/analytics/cycle_analytics/project_stage.rb
+++ b/app/models/analytics/cycle_analytics/project_stage.rb
@@ -3,10 +3,9 @@
module Analytics
module CycleAnalytics
class ProjectStage < ApplicationRecord
- include Analytics::CycleAnalytics::Stage
+ include Analytics::CycleAnalytics::Stageable
- validates :project, presence: true
- belongs_to :project
+ belongs_to :project, optional: false
belongs_to :value_stream, class_name: 'Analytics::CycleAnalytics::ProjectValueStream', foreign_key: :project_value_stream_id
alias_attribute :parent, :project
diff --git a/app/models/appearance.rb b/app/models/appearance.rb
index 4a046b3ab20..3a5e06e9a1c 100644
--- a/app/models/appearance.rb
+++ b/app/models/appearance.rb
@@ -6,7 +6,7 @@ class Appearance < ApplicationRecord
include WithUploads
attribute :title, default: ''
- attribute :short_title, default: ''
+ attribute :pwa_short_name, default: ''
attribute :description, default: ''
attribute :new_project_guidelines, default: ''
attribute :profile_image_guidelines, default: ''
@@ -23,6 +23,7 @@ class Appearance < ApplicationRecord
cache_markdown_field :footer_message, pipeline: :broadcast_message
validates :logo, file_size: { maximum: 1.megabyte }
+ validates :pwa_icon, file_size: { maximum: 1.megabyte }
validates :header_logo, file_size: { maximum: 1.megabyte }
validates :message_background_color, allow_blank: true, color: true
validates :message_font_color, allow_blank: true, color: true
@@ -31,6 +32,7 @@ class Appearance < ApplicationRecord
validate :single_appearance_row, on: :create
mount_uploader :logo, AttachmentUploader
+ mount_uploader :pwa_icon, AttachmentUploader
mount_uploader :header_logo, AttachmentUploader
mount_uploader :favicon, FaviconUploader
@@ -49,6 +51,10 @@ class Appearance < ApplicationRecord
logo_system_path(logo, 'logo')
end
+ def pwa_icon_path
+ logo_system_path(pwa_icon, 'pwa_icon')
+ end
+
def header_logo_path
logo_system_path(header_logo, 'header_logo')
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 3fb1f58f3e0..59ad0650eb3 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -35,6 +35,7 @@ class ApplicationSetting < ApplicationRecord
belongs_to :instance_group, class_name: "Group", foreign_key: 'instance_administrators_group_id'
alias_attribute :instance_group_id, :instance_administrators_group_id
alias_attribute :instance_administrators_group, :instance_group
+ alias_attribute :housekeeping_optimize_repository_period, :housekeeping_incremental_repack_period
sanitizes! :default_branch_name
@@ -256,18 +257,10 @@ class ApplicationSetting < ApplicationRecord
presence: { message: 'Domain denylist cannot be empty if denylist is enabled.' },
if: :domain_denylist_enabled?
- validates :housekeeping_incremental_repack_period,
+ validates :housekeeping_optimize_repository_period,
presence: true,
numericality: { only_integer: true, greater_than: 0 }
- validates :housekeeping_full_repack_period,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: :housekeeping_incremental_repack_period }
-
- validates :housekeeping_gc_period,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: :housekeeping_full_repack_period }
-
validates :terminal_max_session_time,
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
@@ -413,7 +406,7 @@ class ApplicationSetting < ApplicationRecord
validates :invisible_captcha_enabled,
inclusion: { in: [true, false], message: N_('must be a boolean value') }
- validates :invitation_flow_enforcement, :can_create_group,
+ validates :invitation_flow_enforcement, :can_create_group, :user_defaults_to_private_profile,
allow_nil: false,
inclusion: { in: [true, false], message: N_('must be a boolean value') }
@@ -694,6 +687,10 @@ class ApplicationSetting < ApplicationRecord
allow_nil: false,
inclusion: { in: [true, false], message: N_('must be a boolean value') }
+ validates :allow_runner_registration_token,
+ allow_nil: false,
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
+
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 229c4e68d79..8ef7e9a92a8 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -245,7 +245,9 @@ module ApplicationSettingImplementation
users_get_by_id_limit: 300,
users_get_by_id_limit_allowlist: [],
can_create_group: true,
- bulk_import_enabled: false
+ bulk_import_enabled: false,
+ allow_runner_registration_token: true,
+ user_defaults_to_private_profile: false
}
end
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index e49c4e09a50..ebca5e90313 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -152,6 +152,10 @@ class BulkImports::Entity < ApplicationRecord
"::#{pluralized_name.capitalize}::UpdateService".constantize
end
+ def full_path
+ project? ? project&.full_path : group&.full_path
+ end
+
private
def validate_parent_is_a_group
diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb
index 60370c525d5..9bd618c1008 100644
--- a/app/models/chat_name.rb
+++ b/app/models/chat_name.rb
@@ -7,12 +7,10 @@ class ChatName < ApplicationRecord
belongs_to :user
validates :user, presence: true
- validates :integration, presence: true
validates :team_id, presence: true
validates :chat_id, presence: true
- validates :user_id, uniqueness: { scope: [:integration_id] }
- validates :chat_id, uniqueness: { scope: [:integration_id, :team_id] }
+ validates :chat_id, uniqueness: { scope: :team_id }
# Updates the "last_used_timestamp" but only if it wasn't already updated
# recently.
diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb
index 76d4b9d6206..f87b18d516f 100644
--- a/app/models/ci/artifact_blob.rb
+++ b/app/models/ci/artifact_blob.rb
@@ -46,7 +46,7 @@ module Ci
'artifacts', path
].join('/')
- "#{project.pages_group_url}/#{artifact_path}"
+ "#{project.pages_namespace_url}/#{artifact_path}"
end
def external_link?(job)
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 662fb3cffa8..4af31fd37f2 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -19,11 +19,6 @@ module Ci
belongs_to :project
belongs_to :trigger_request
- # To be removed upon :ci_bridge_remove_sourced_pipelines feature flag removal
- has_many :sourced_pipelines, class_name: "::Ci::Sources::Pipeline",
- foreign_key: :source_job_id,
- inverse_of: :source_bridge
-
has_one :downstream_pipeline, through: :sourced_pipeline, source: :pipeline
validates :ref, presence: true
@@ -89,20 +84,8 @@ module Ci
end
end
- def sourced_pipelines
- if Feature.enabled?(:ci_bridge_remove_sourced_pipelines, project)
- raise 'Ci::Bridge does not have sourced_pipelines association'
- end
-
- super
- end
-
def has_downstream_pipeline?
- if Feature.enabled?(:ci_bridge_remove_sourced_pipelines, project)
- sourced_pipeline.present?
- else
- sourced_pipelines.exists?
- end
+ sourced_pipeline.present?
end
def downstream_pipeline_params
@@ -298,7 +281,7 @@ module Ci
return [] unless forward_yaml_variables?
yaml_variables.to_a.map do |hash|
- if hash[:raw] && ci_raw_variables_in_yaml_config_enabled?
+ if hash[:raw]
{ key: hash[:key], value: hash[:value], raw: true }
else
{ key: hash[:key], value: ::ExpandVariables.expand(hash[:value], expand_variables) }
@@ -310,7 +293,7 @@ module Ci
return [] unless forward_pipeline_variables?
pipeline.variables.to_a.map do |variable|
- if variable.raw? && ci_raw_variables_in_yaml_config_enabled?
+ if variable.raw?
{ key: variable.key, value: variable.value, raw: true }
else
{ key: variable.key, value: ::ExpandVariables.expand(variable.value, expand_variables) }
@@ -323,7 +306,7 @@ module Ci
return [] unless pipeline.pipeline_schedule
pipeline.pipeline_schedule.variables.to_a.map do |variable|
- if variable.raw? && ci_raw_variables_in_yaml_config_enabled?
+ if variable.raw?
{ key: variable.key, value: variable.value, raw: true }
else
{ key: variable.key, value: ::ExpandVariables.expand(variable.value, expand_variables) }
@@ -346,12 +329,6 @@ 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 7f42b21bc87..0139b025d98 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -68,6 +68,7 @@ module Ci
delegate :service_specification, to: :runner_session, allow_nil: true
delegate :gitlab_deploy_token, to: :project
delegate :harbor_integration, to: :project
+ delegate :apple_app_store_integration, to: :project
delegate :trigger_short_token, to: :trigger_request, allow_nil: true
delegate :ensure_persistent_ref, to: :pipeline
delegate :enable_debug_trace!, to: :metadata
@@ -587,6 +588,7 @@ module Ci
.append(key: 'CI_REPOSITORY_URL', value: repo_url.to_s, public: false)
.concat(deploy_token_variables)
.concat(harbor_variables)
+ .concat(apple_app_store_variables)
end
end
@@ -630,6 +632,13 @@ module Ci
Gitlab::Ci::Variables::Collection.new(harbor_integration.ci_variables)
end
+ def apple_app_store_variables
+ return [] unless apple_app_store_integration.try(:activated?)
+ return [] unless pipeline.protected_ref?
+
+ Gitlab::Ci::Variables::Collection.new(apple_app_store_integration.ci_variables)
+ end
+
def features
{
trace_sections: true,
@@ -736,6 +745,12 @@ module Ci
self.token && token.present? && ActiveSupport::SecurityUtils.secure_compare(token, self.token)
end
+ def remove_token!
+ if Feature.enabled?(:remove_job_token_on_completion, project)
+ update!(token_encrypted: nil)
+ end
+ end
+
# acts_as_taggable uses this method create/remove tags with contexts
# defined by taggings and to get those contexts it executes a query.
# We don't use any other contexts except `tags`, so we don't need it.
@@ -884,8 +899,9 @@ module Ci
return cache unless project.ci_separated_caches
- type_suffix = pipeline.protected_ref? ? 'protected' : 'non_protected'
cache.map do |entry|
+ type_suffix = !entry[:unprotect] && pipeline.protected_ref? ? 'protected' : 'non_protected'
+
entry.merge(key: "#{entry[:key]}-#{type_suffix}")
end
end
@@ -1135,15 +1151,9 @@ module Ci
end
end
- def partition_id_token_prefix
- partition_id.to_s(16) if Feature.enabled?(:ci_build_partition_id_token_prefix, project)
- end
-
override :format_token
def format_token(token)
- return token if partition_id_token_prefix.nil?
-
- "#{partition_id_token_prefix}_#{token}"
+ "#{partition_id.to_s(16)}_#{token}"
end
protected
diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb
index 9b4794abb2e..1dcb9190f11 100644
--- a/app/models/ci/build_metadata.rb
+++ b/app/models/ci/build_metadata.rb
@@ -71,7 +71,7 @@ module Ci
end
def timeout_with_highest_precedence
- [(job_timeout || project_timeout), runner_timeout].compact.min_by { |timeout| timeout.value }
+ [(job_timeout || project_timeout), runner_timeout].compact.min_by(&:value)
end
def project_timeout
diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb
index 57d8b9ba368..c5f6e54c336 100644
--- a/app/models/ci/build_trace_chunk.rb
+++ b/app/models/ci/build_trace_chunk.rb
@@ -166,7 +166,7 @@ module Ci
raise FailedToPersistDataError, 'Modifed build trace chunk detected' if has_changes_to_save?
self.class.with_read_consistency(build) do
- self.reset.then { |chunk| chunk.unsafe_persist_data! }
+ self.reset.then(&:unsafe_persist_data!)
end
end
rescue FailedToObtainLockError
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 53c358f4eba..0dca5b18a24 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -14,6 +14,8 @@ module Ci
include EachBatch
include Gitlab::Utils::StrongMemoize
+ enum accessibility: { public: 0, private: 1 }, _suffix: true
+
NON_ERASABLE_FILE_TYPES = %w[trace].freeze
REPORT_FILE_TYPES = {
@@ -346,6 +348,12 @@ module Ci
end
end
+ def public_access?
+ return true unless Feature.enabled?(:non_public_artifacts, type: :development)
+
+ public_accessibility?
+ end
+
private
def store_file_in_transaction!
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 05207fb1ca0..eab2ab69e44 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -919,8 +919,12 @@ module Ci
Gitlab::Ci::Variables::Collection.new.tap do |variables|
next variables unless tag?
+ git_tag = project.repository.find_tag(ref)
+
+ next variables unless git_tag
+
variables.append(key: 'CI_COMMIT_TAG', value: ref)
- variables.append(key: 'CI_COMMIT_TAG_MESSAGE', value: project.repository.find_tag(ref).message)
+ variables.append(key: 'CI_COMMIT_TAG_MESSAGE', value: git_tag.message)
# legacy variable
variables.append(key: 'CI_BUILD_TAG', value: ref)
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index a7f3ff938c3..bac85b6095e 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -13,6 +13,7 @@ module Ci
include TaggableQueries
include Presentable
include EachBatch
+ include Ci::HasRunnerExecutor
add_authentication_token_field :token, encrypted: :optional, expires_at: :compute_token_expiration
@@ -27,21 +28,6 @@ module Ci
project_type: 3
}
- enum executor_type: {
- unknown: 0,
- custom: 1,
- shell: 2,
- docker: 3,
- docker_windows: 4,
- docker_ssh: 5,
- ssh: 6,
- parallels: 7,
- virtualbox: 8,
- docker_machine: 9,
- docker_ssh_machine: 10,
- kubernetes: 11
- }, _suffix: true
-
# This `ONLINE_CONTACT_TIMEOUT` needs to be larger than
# `RUNNER_QUEUE_EXPIRY_TIME+UPDATE_CONTACT_COLUMN_EVERY`
#
@@ -68,6 +54,7 @@ module Ci
TAG_LIST_MAX_LENGTH = 50
+ has_many :runner_machines, inverse_of: :runner
has_many :builds
has_many :runner_projects, inverse_of: :runner, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, through: :runner_projects, disable_joins: true
@@ -77,6 +64,8 @@ module Ci
has_one :last_build, -> { order('id DESC') }, class_name: 'Ci::Build'
has_one :runner_version, primary_key: :version, foreign_key: :version, class_name: 'Ci::RunnerVersion'
+ belongs_to :creator, class_name: 'User', optional: true
+
before_save :ensure_token
scope :active, -> (value = true) { where(active: value) }
@@ -440,7 +429,9 @@ module Ci
::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do
values = values&.slice(:version, :revision, :platform, :architecture, :ip_address, :config, :executor) || {}
values[:contacted_at] = Time.current
- values[:executor_type] = EXECUTOR_NAME_TO_TYPES.fetch(values.delete(:executor), :unknown)
+ if values.include?(:executor)
+ values[:executor_type] = EXECUTOR_NAME_TO_TYPES.fetch(values.delete(:executor), :unknown)
+ end
cache_attributes(values)
diff --git a/app/models/ci/runner_machine.rb b/app/models/ci/runner_machine.rb
new file mode 100644
index 00000000000..1dd997a8ee1
--- /dev/null
+++ b/app/models/ci/runner_machine.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Ci
+ class RunnerMachine < Ci::ApplicationRecord
+ include FromUnion
+ include Ci::HasRunnerExecutor
+
+ belongs_to :runner
+
+ validates :runner, presence: true
+ validates :machine_xid, presence: true, length: { maximum: 64 }
+ validates :version, length: { maximum: 2048 }
+ validates :revision, length: { maximum: 255 }
+ validates :platform, length: { maximum: 255 }
+ validates :architecture, length: { maximum: 255 }
+ validates :ip_address, length: { maximum: 1024 }
+ validates :config, json_schema: { filename: 'ci_runner_config' }
+
+ # The `STALE_TIMEOUT` constant defines the how far past the last contact or creation date a runner machine
+ # will be considered stale
+ STALE_TIMEOUT = 7.days
+
+ scope :stale, -> do
+ created_some_time_ago = arel_table[:created_at].lteq(STALE_TIMEOUT.ago)
+ contacted_some_time_ago = arel_table[:contacted_at].lteq(STALE_TIMEOUT.ago)
+
+ from_union(
+ where(contacted_at: nil),
+ where(contacted_some_time_ago),
+ remove_duplicates: false).where(created_some_time_ago)
+ end
+ end
+end
diff --git a/app/models/clusters/concerns/provider_status.rb b/app/models/clusters/concerns/provider_status.rb
index 2da1ee7aabb..44da840bec3 100644
--- a/app/models/clusters/concerns/provider_status.rb
+++ b/app/models/clusters/concerns/provider_status.rb
@@ -24,7 +24,7 @@ module Clusters
transition any - [:errored] => :errored
end
- before_transition any => [:errored, :created] do |provider|
+ before_transition any => [:errored, :created] do |provider, _|
provider.nullify_credentials
end
diff --git a/app/models/clusters/providers/aws.rb b/app/models/clusters/providers/aws.rb
index f0f56d9ebd9..969820459e3 100644
--- a/app/models/clusters/providers/aws.rb
+++ b/app/models/clusters/providers/aws.rb
@@ -45,18 +45,6 @@ module Clusters
)
end
- def api_client
- strong_memoize(:api_client) do
- ::Aws::CloudFormation::Client.new(credentials: credentials, region: region)
- end
- end
-
- def credentials
- strong_memoize(:credentials) do
- ::Aws::Credentials.new(access_key_id, secret_access_key, session_token)
- end
- end
-
def has_rbac_enabled?
true
end
diff --git a/app/models/clusters/providers/gcp.rb b/app/models/clusters/providers/gcp.rb
index fde5ed592cb..6f39037b947 100644
--- a/app/models/clusters/providers/gcp.rb
+++ b/app/models/clusters/providers/gcp.rb
@@ -37,12 +37,6 @@ module Clusters
greater_than: 0
}
- def api_client
- return unless access_token
-
- @api_client ||= GoogleApi::CloudPlatform::Client.new(access_token, nil)
- end
-
def nullify_credentials
assign_attributes(
access_token: nil,
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 5175842e5de..a95ab756600 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -359,10 +359,6 @@ class Commit
end
def has_signature?
- if signature_type == :SSH && !ssh_signatures_enabled?
- return false
- end
-
signature_type && signature_type != :NONE
end
@@ -382,10 +378,6 @@ class Commit
@signature_type ||= raw_signature_type || :NONE
end
- def ssh_signatures_enabled?
- Feature.enabled?(:ssh_commit_signatures, project)
- end
-
def signature
strong_memoize(:signature) do
case signature_type
@@ -394,7 +386,7 @@ class Commit
when :X509
Gitlab::X509::Commit.new(self).signature
when :SSH
- Gitlab::Ssh::Commit.new(self).signature if ssh_signatures_enabled?
+ Gitlab::Ssh::Commit.new(self).signature
else
nil
end
@@ -584,9 +576,7 @@ class Commit
private
def expire_note_etag_cache_for_related_mrs
- MergeRequest.includes(target_project: :namespace).by_commit_sha(id).find_each do |mr|
- mr.expire_note_etag_cache
- end
+ MergeRequest.includes(target_project: :namespace).by_commit_sha(id).find_each(&:expire_note_etag_cache)
end
def commit_reference(from, referable_commit_id, full: false)
diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb
index 7d89ddde0cb..47ecdfa8574 100644
--- a/app/models/commit_collection.rb
+++ b/app/models/commit_collection.rb
@@ -25,7 +25,7 @@ class CommitCollection
end
def committers
- emails = without_merge_commits.map(&:committer_email).uniq
+ emails = without_merge_commits.filter_map(&:committer_email).uniq
User.by_any_email(emails)
end
diff --git a/app/models/commit_signatures/ssh_signature.rb b/app/models/commit_signatures/ssh_signature.rb
index 1e64e2b2978..e9e16651d1c 100644
--- a/app/models/commit_signatures/ssh_signature.rb
+++ b/app/models/commit_signatures/ssh_signature.rb
@@ -6,13 +6,18 @@ module CommitSignatures
include SignatureType
belongs_to :key, optional: true
+ belongs_to :user, optional: true
def type
:ssh
end
def signed_by_user
- key&.user
+ user || key&.user
+ end
+
+ def key_fingerprint_sha256
+ super || key&.fingerprint_sha256
end
end
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 2470eada62e..64e585bae14 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -71,6 +71,7 @@ class CommitStatus < Ci::ApplicationRecord
scope :scheduled_at_before, ->(date) {
where('ci_builds.scheduled_at IS NOT NULL AND ci_builds.scheduled_at < ?', date)
}
+ scope :with_when_executed, ->(when_executed) { where(when: when_executed) }
# The scope applies `pluck` to split the queries. Use with care.
scope :for_project_paths, -> (paths) do
diff --git a/app/models/concerns/analytics/cycle_analytics/parentable.rb b/app/models/concerns/analytics/cycle_analytics/parentable.rb
new file mode 100644
index 00000000000..785f6eea6bf
--- /dev/null
+++ b/app/models/concerns/analytics/cycle_analytics/parentable.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Analytics
+ module CycleAnalytics
+ module Parentable
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :namespace, class_name: 'Namespace', foreign_key: :group_id, optional: false # rubocop: disable Rails/InverseOf
+
+ validate :ensure_namespace_type
+
+ def ensure_namespace_type
+ return if namespace.nil?
+ return if namespace.is_a?(::Namespaces::ProjectNamespace) || namespace.is_a?(::Group)
+
+ errors.add(:namespace, s_('CycleAnalytics|the assigned object is not supported'))
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stageable.rb
index d9e6756ab86..d1f948d1366 100644
--- a/app/models/concerns/analytics/cycle_analytics/stage.rb
+++ b/app/models/concerns/analytics/cycle_analytics/stageable.rb
@@ -2,7 +2,7 @@
module Analytics
module CycleAnalytics
- module Stage
+ module Stageable
extend ActiveSupport::Concern
include RelativePositioning
include Gitlab::Utils::StrongMemoize
@@ -10,7 +10,7 @@ module Analytics
included do
belongs_to :start_event_label, class_name: 'GroupLabel', optional: true
belongs_to :end_event_label, class_name: 'GroupLabel', optional: true
- belongs_to :stage_event_hash, class_name: 'Analytics::CycleAnalytics::StageEventHash', foreign_key: :stage_event_hash_id, optional: true
+ belongs_to :stage_event_hash, class_name: 'Analytics::CycleAnalytics::StageEventHash', optional: true
validates :name, presence: true
validates :name, exclusion: { in: Gitlab::Analytics::CycleAnalytics::DefaultStages.names }, if: :custom?
@@ -21,39 +21,31 @@ module Analytics
validate :validate_stage_event_pairs
validate :validate_labels
- enum start_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum, _prefix: :start_event_identifier
- enum end_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum, _prefix: :end_event_identifier
+ enum start_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum,
+ _prefix: :start_event_identifier
+ enum end_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum,
+ _prefix: :end_event_identifier
alias_attribute :custom_stage?, :custom
scope :default_stages, -> { where(custom: false) }
scope :ordered, -> { order(:relative_position, :id) }
scope :with_preloaded_labels, -> { includes(:start_event_label, :end_event_label) }
scope :for_list, -> { with_preloaded_labels.ordered }
- scope :by_value_stream, -> (value_stream) { where(value_stream_id: value_stream.id) }
+ scope :by_value_stream, ->(value_stream) { where(value_stream_id: value_stream.id) }
before_save :ensure_stage_event_hash_id
after_commit :cleanup_old_stage_event_hash
end
- def parent=(_)
- raise NotImplementedError
- end
-
- def parent
- raise NotImplementedError
- end
-
def start_event
- strong_memoize(:start_event) do
- Gitlab::Analytics::CycleAnalytics::StageEvents[start_event_identifier].new(params_for_start_event)
- end
+ Gitlab::Analytics::CycleAnalytics::StageEvents[start_event_identifier].new(params_for_start_event)
end
+ strong_memoize_attr :start_event
def end_event
- strong_memoize(:end_event) do
- Gitlab::Analytics::CycleAnalytics::StageEvents[end_event_identifier].new(params_for_end_event)
- end
+ Gitlab::Analytics::CycleAnalytics::StageEvents[end_event_identifier].new(params_for_end_event)
end
+ strong_memoize_attr :end_event
def events_hash_code
Digest::SHA256.hexdigest("#{start_event.hash_code}-#{end_event.hash_code}")
@@ -109,9 +101,9 @@ module Analytics
def validate_stage_event_pairs
return if start_event_identifier.nil? || end_event_identifier.nil?
- unless pairing_rules.fetch(start_event.class, []).include?(end_event.class)
- errors.add(:end_event, s_('CycleAnalytics|not allowed for the given start event'))
- end
+ return if pairing_rules.fetch(start_event.class, []).include?(end_event.class)
+
+ errors.add(:end_event, s_('CycleAnalytics|not allowed for the given start event'))
end
def pairing_rules
@@ -119,21 +111,23 @@ module Analytics
end
def validate_labels
- validate_label_within_group(:start_event_label_id, start_event_label_id) if start_event_label_id_changed?
- validate_label_within_group(:end_event_label_id, end_event_label_id) if end_event_label_id_changed?
+ validate_label_within_namespace(:start_event_label_id, start_event_label_id) if start_event_label_id_changed?
+ validate_label_within_namespace(:end_event_label_id, end_event_label_id) if end_event_label_id_changed?
end
- def validate_label_within_group(association_name, label_id)
+ def validate_label_within_namespace(association_name, label_id)
return unless label_id
- return unless group
- unless label_available_for_group?(label_id)
- errors.add(association_name, s_('CycleAnalyticsStage|is not available for the selected group'))
- end
+ return if label_available_for_namespace?(label_id)
+
+ errors.add(association_name, s_('CycleAnalyticsStage|is not available for the selected group'))
end
- def label_available_for_group?(label_id)
- LabelsFinder.new(nil, { group_id: group.id, include_ancestor_groups: true, only_group_labels: true })
+ def label_available_for_namespace?(label_id)
+ subject = is_a?(::Analytics::CycleAnalytics::Stage) ? namespace : project.group
+ return unless subject
+
+ LabelsFinder.new(nil, { group_id: subject.id, include_ancestor_groups: true, only_group_labels: true })
.execute(skip_authorization: true)
.id_in(label_id)
.exists?
@@ -142,15 +136,15 @@ module Analytics
def ensure_stage_event_hash_id
previous_stage_event_hash = stage_event_hash&.hash_sha256
- if previous_stage_event_hash.blank? || events_hash_code != previous_stage_event_hash
- self.stage_event_hash_id = Analytics::CycleAnalytics::StageEventHash.record_id_by_hash_sha256(events_hash_code)
- end
+ return unless previous_stage_event_hash.blank? || events_hash_code != previous_stage_event_hash
+
+ self.stage_event_hash_id = Analytics::CycleAnalytics::StageEventHash.record_id_by_hash_sha256(events_hash_code)
end
def cleanup_old_stage_event_hash
- if stage_event_hash_id_previously_changed? && stage_event_hash_id_previously_was
- Analytics::CycleAnalytics::StageEventHash.cleanup_if_unused(stage_event_hash_id_previously_was)
- end
+ return unless stage_event_hash_id_previously_changed? && stage_event_hash_id_previously_was
+
+ Analytics::CycleAnalytics::StageEventHash.cleanup_if_unused(stage_event_hash_id_previously_was)
end
end
end
diff --git a/app/models/concerns/board_recent_visit.rb b/app/models/concerns/board_recent_visit.rb
index fd4d574ac58..c1c8307500e 100644
--- a/app/models/concerns/board_recent_visit.rb
+++ b/app/models/concerns/board_recent_visit.rb
@@ -9,9 +9,7 @@ module BoardRecentVisit
"user" => user,
board_parent_relation => board.resource_parent,
board_relation => board
- ).tap do |visit|
- visit.touch
- end
+ ).tap(&:touch)
rescue ActiveRecord::RecordNotUnique
retry
end
diff --git a/app/models/concerns/ci/has_runner_executor.rb b/app/models/concerns/ci/has_runner_executor.rb
new file mode 100644
index 00000000000..dc70cdb2018
--- /dev/null
+++ b/app/models/concerns/ci/has_runner_executor.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Ci
+ module HasRunnerExecutor
+ extend ActiveSupport::Concern
+
+ included do
+ enum executor_type: {
+ unknown: 0,
+ custom: 1,
+ shell: 2,
+ docker: 3,
+ docker_windows: 4,
+ docker_ssh: 5,
+ ssh: 6,
+ parallels: 7,
+ virtualbox: 8,
+ docker_machine: 9,
+ docker_ssh_machine: 10,
+ kubernetes: 11
+ }, _suffix: true
+ end
+ end
+end
diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb
index f1efbba67e1..784afd1f231 100644
--- a/app/models/concerns/counter_attribute.rb
+++ b/app/models/concerns/counter_attribute.rb
@@ -88,12 +88,20 @@ module CounterAttribute
end
def increment_counter(attribute, increment)
- return if increment == 0
+ return if increment.amount == 0
run_after_commit_or_now do
new_value = counter(attribute).increment(increment)
- log_increment_counter(attribute, increment, new_value)
+ log_increment_counter(attribute, increment.amount, new_value)
+ end
+ end
+
+ def bulk_increment_counter(attribute, increments)
+ run_after_commit_or_now do
+ new_value = counter(attribute).bulk_increment(increments)
+
+ log_increment_counter(attribute, increments.sum(&:amount), new_value)
end
end
@@ -103,14 +111,22 @@ module CounterAttribute
end
end
- def reset_counter!(attribute)
+ def initiate_refresh!(attribute)
+ raise ArgumentError, %(attribute "#{attribute}" cannot be refreshed) unless counter_attribute_enabled?(attribute)
+
detect_race_on_record(log_fields: { caller: __method__, attributes: attribute }) do
- counter(attribute).reset!
+ counter(attribute).initiate_refresh!
end
log_clear_counter(attribute)
end
+ def finalize_refresh(attribute)
+ raise ArgumentError, %(attribute "#{attribute}" cannot be refreshed) unless counter_attribute_enabled?(attribute)
+
+ counter(attribute).finalize_refresh
+ end
+
def execute_after_commit_callbacks
self.class.after_commit_callbacks.each do |callback|
callback.call(self.reset)
@@ -122,11 +138,17 @@ module CounterAttribute
def build_counter_for(attribute)
raise ArgumentError, %(attribute "#{attribute}" does not exist) unless has_attribute?(attribute)
- if counter_attribute_enabled?(attribute)
- Gitlab::Counters::BufferedCounter.new(self, attribute)
- else
- Gitlab::Counters::LegacyCounter.new(self, attribute)
- end
+ return legacy_counter(attribute) unless counter_attribute_enabled?(attribute)
+
+ buffered_counter(attribute)
+ end
+
+ def legacy_counter(attribute)
+ Gitlab::Counters::LegacyCounter.new(self, attribute)
+ end
+
+ def buffered_counter(attribute)
+ Gitlab::Counters::BufferedCounter.new(self, attribute)
end
def database_lock_key
diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb
index 1af655277b8..b02c95c9662 100644
--- a/app/models/concerns/has_user_type.rb
+++ b/app/models/concerns/has_user_type.rb
@@ -14,20 +14,32 @@ module HasUserType
migration_bot: 7,
security_bot: 8,
automation_bot: 9,
- admin_bot: 11
+ admin_bot: 11,
+ suggested_reviewers_bot: 12
}.with_indifferent_access.freeze
- BOT_USER_TYPES = %w[alert_bot project_bot support_bot visual_review_bot migration_bot security_bot automation_bot admin_bot].freeze
+ BOT_USER_TYPES = %w[
+ alert_bot
+ project_bot
+ support_bot
+ visual_review_bot
+ migration_bot
+ security_bot
+ automation_bot
+ admin_bot
+ suggested_reviewers_bot
+ ].freeze
+
NON_INTERNAL_USER_TYPES = %w[human project_bot service_user].freeze
INTERNAL_USER_TYPES = (USER_TYPES.keys - NON_INTERNAL_USER_TYPES).freeze
included do
scope :humans, -> { where(user_type: :human) }
scope :bots, -> { where(user_type: BOT_USER_TYPES) }
- scope :without_bots, -> { humans.or(where.not(user_type: BOT_USER_TYPES)) }
+ scope :without_bots, -> { humans.or(where(user_type: USER_TYPES.keys - BOT_USER_TYPES)) }
scope :non_internal, -> { humans.or(where(user_type: NON_INTERNAL_USER_TYPES)) }
- scope :without_ghosts, -> { humans.or(where.not(user_type: :ghost)) }
- scope :without_project_bot, -> { humans.or(where.not(user_type: :project_bot)) }
+ scope :without_ghosts, -> { humans.or(where(user_type: USER_TYPES.keys - ['ghost'])) }
+ scope :without_project_bot, -> { humans.or(where(user_type: USER_TYPES.keys - ['project_bot'])) }
scope :human_or_service_user, -> { humans.or(where(user_type: :service_user)) }
enum user_type: USER_TYPES
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index 492d55c74e2..eed396f785b 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -88,7 +88,7 @@ module Noteable
def discussions
@discussions ||= discussion_notes
- .inc_relations_for_view
+ .inc_relations_for_view(self)
.discussions(self)
end
@@ -126,7 +126,7 @@ module Noteable
def grouped_diff_discussions(*args)
# Doesn't use `discussion_notes`, because this may include commit diff notes
# besides MR diff notes, that we do not want to display on the MR Changes tab.
- notes.inc_relations_for_view.grouped_diff_discussions(*args)
+ notes.inc_relations_for_view(self).grouped_diff_discussions(*args)
end
# rubocop:disable Gitlab/ModuleWithInstanceVariables
@@ -205,6 +205,14 @@ module Noteable
model_name.singular
end
+ def commenters(user: nil)
+ eligable_notes = notes.user
+
+ eligable_notes = eligable_notes.not_internal unless user&.can?(:read_internal_note, self)
+
+ User.where(id: eligable_notes.select(:author_id).distinct)
+ end
+
private
# Synthetic system notes don't have discussion IDs because these are generated dynamically
diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb
index d37f20e2e7c..b910c0ab5c2 100644
--- a/app/models/concerns/project_features_compatibility.rb
+++ b/app/models/concerns/project_features_compatibility.rb
@@ -124,8 +124,13 @@ module ProjectFeaturesCompatibility
private
def write_feature_attribute_boolean(field, value)
- access_level = Gitlab::Utils.to_boolean(value) ? ProjectFeature::ENABLED : ProjectFeature::DISABLED
- write_feature_attribute_raw(field, access_level)
+ value_type = Gitlab::Utils.to_boolean(value)
+ if value_type.in?([true, false])
+ access_level = value_type ? ProjectFeature::ENABLED : ProjectFeature::DISABLED
+ write_feature_attribute_raw(field, access_level)
+ else
+ write_feature_attribute_string(field, value)
+ end
end
def write_feature_attribute_string(field, value)
diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb
index 92a88d2f7c8..141c480ea1f 100644
--- a/app/models/concerns/resolvable_discussion.rb
+++ b/app/models/concerns/resolvable_discussion.rb
@@ -96,7 +96,7 @@ module ResolvableDiscussion
def unresolve!
return unless resolvable?
- update { |notes| notes.unresolve! }
+ update(&:unresolve!)
end
def clear_memoized_values
diff --git a/app/models/concerns/safely_change_column_default.rb b/app/models/concerns/safely_change_column_default.rb
new file mode 100644
index 00000000000..567f690d950
--- /dev/null
+++ b/app/models/concerns/safely_change_column_default.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+# == SafelyChangeColumnDefault concern.
+#
+# Contains functionality that allows safely changing a column default without downtime.
+# Without this concern, Rails can mutate the old default value to the new default value if the old default is explicitly
+# specified.
+#
+# Usage:
+#
+# class SomeModel < ApplicationRecord
+# include SafelyChangeColumnDefault
+#
+# columns_changing_default :value
+# end
+#
+# # Assume a default of 100 for value
+# SomeModel.create!(value: 100) # INSERT INTO some_model (value) VALUES (100)
+# change_column_default('some_model', 'value', from: 100, to: 101)
+# SomeModel.create!(value: 100) # INSERT INTO some_model (value) VALUES (100)
+# # Without this concern, would be INSERT INTO some_model (value) DEFAULT VALUES and would insert 101.
+module SafelyChangeColumnDefault
+ extend ActiveSupport::Concern
+
+ class_methods do
+ # Indicate that one or more columns will have their database default change.
+ #
+ # By indicating those columns here, this helper prevents a case where explicitly writing the old database default
+ # will be mutated to the new database default.
+ def columns_changing_default(*columns)
+ self.columns_with_changing_default = columns.map(&:to_s)
+ end
+ end
+
+ included do
+ class_attribute :columns_with_changing_default, default: []
+
+ before_create do
+ columns_with_changing_default.to_a.each do |attr_name|
+ attr = @attributes[attr_name]
+
+ attribute_will_change!(attr_name) if !attr.changed? && attr.came_from_user?
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb
index 586f1dbb65c..89398537e0a 100644
--- a/app/models/concerns/update_project_statistics.rb
+++ b/app/models/concerns/update_project_statistics.rb
@@ -78,9 +78,10 @@ module UpdateProjectStatistics
return if delta == 0
return if project.nil?
+ increment = Gitlab::Counters::Increment.new(amount: delta, ref: id)
+
run_after_commit do
- ProjectStatistics.increment_statistic(
- project, self.class.project_statistics_name, delta)
+ ProjectStatistics.increment_statistic(project, self.class.project_statistics_name, increment)
end
end
end
diff --git a/app/models/concerns/work_item_resource_event.rb b/app/models/concerns/work_item_resource_event.rb
new file mode 100644
index 00000000000..d0323feb029
--- /dev/null
+++ b/app/models/concerns/work_item_resource_event.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module WorkItemResourceEvent
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :work_item, foreign_key: 'issue_id'
+ end
+
+ def work_item_synthetic_system_note(events: nil)
+ # System notes for label resource events are handled in batches, so that we have single system note for multiple
+ # label changes.
+ if is_a?(ResourceLabelEvent) && events.present?
+ return synthetic_note_class.from_events(events, resource: work_item, resource_parent: work_item.project)
+ end
+
+ synthetic_note_class.from_event(self, resource: work_item, resource_parent: work_item.project)
+ end
+
+ def synthetic_note_class
+ raise NoMethodError, 'must implement `synthetic_note_class` method'
+ end
+end
diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb
index 2563fd484f1..aaafa396337 100644
--- a/app/models/deploy_key.rb
+++ b/app/models/deploy_key.rb
@@ -19,7 +19,7 @@ class DeployKey < Key
scope :with_projects, -> { includes(deploy_keys_projects: { project: [:route, namespace: :route] }) }
scope :including_projects_with_write_access, -> { includes(:projects_with_write_access) }
- accepts_nested_attributes_for :deploy_keys_projects
+ accepts_nested_attributes_for :deploy_keys_projects, reject_if: :reject_deploy_keys_projects?
def private?
!public?
@@ -72,4 +72,10 @@ class DeployKey < Key
def impersonated?
false
end
+
+ private
+
+ def reject_deploy_keys_projects?
+ !self.valid?
+ end
end
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 1254ce1c90a..1ae7d9925a5 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -103,15 +103,6 @@ class Deployment < ApplicationRecord
deployment.finished_at = Time.current
end
- after_transition any => :running do |deployment|
- next unless deployment.project.ci_forward_deployment_enabled?
- next if Feature.enabled?(:prevent_outdated_deployment_jobs, deployment.project)
-
- deployment.run_after_commit do
- Deployments::DropOlderDeploymentsWorker.perform_async(id)
- end
- end
-
after_transition any => :running do |deployment, transition|
deployment.run_after_commit do
Deployments::HooksWorker.perform_async(deployment_id: id, status: transition.to, status_changed_at: Time.current)
@@ -303,7 +294,7 @@ class Deployment < ApplicationRecord
end
def older_than_last_successful_deployment?
- last_deployment_id = environment.last_deployment&.id
+ last_deployment_id = environment&.last_deployment&.id
return false unless last_deployment_id.present?
return false if self.id == last_deployment_id
diff --git a/app/models/description_version.rb b/app/models/description_version.rb
index 96c8553c101..fb61b7f5fde 100644
--- a/app/models/description_version.rb
+++ b/app/models/description_version.rb
@@ -6,6 +6,8 @@ class DescriptionVersion < ApplicationRecord
validate :exactly_one_issuable
+ delegate :resource_parent, to: :issuable
+
def self.issuable_attrs
%i(issue merge_request).freeze
end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index f1edfb3a34b..7d99f10822d 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -98,6 +98,27 @@ class Environment < ApplicationRecord
scope :auto_stoppable, -> (limit) { available.where('auto_stop_at < ?', Time.zone.now).limit(limit) }
scope :auto_deletable, -> (limit) { stopped.where('auto_delete_at < ?', Time.zone.now).limit(limit) }
+ scope :deployed_and_updated_before, -> (project_id, before) do
+ # this query joins deployments and filters out any environment that has recent deployments
+ joins = %{
+ LEFT JOIN "deployments" on "deployments".environment_id = "environments".id
+ AND "deployments".project_id = #{project_id}
+ AND "deployments".updated_at >= #{connection.quote(before)}
+ }
+ Environment.joins(joins)
+ .where(project_id: project_id, updated_at: ...before)
+ .group('id', 'deployments.id')
+ .having('deployments.id IS NULL')
+ end
+ scope :without_protected, -> (project) {} # no-op when not in EE mode
+
+ scope :without_names, -> (names) do
+ where.not(name: names)
+ end
+ scope :without_tiers, -> (tiers) do
+ where.not(tier: tiers)
+ end
+
##
# Search environments which have names like the given query.
# Do not set a large limit unless you've confirmed that it works on gitlab.com scale.
diff --git a/app/models/event.rb b/app/models/event.rb
index ed65b367b8a..333841b1f90 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -31,6 +31,7 @@ class Event < ApplicationRecord
DESIGN_ACTIONS = [:created, :updated, :destroyed].freeze
TEAM_ACTIONS = [:joined, :left, :expired].freeze
ISSUE_ACTIONS = [:created, :updated, :closed, :reopened].freeze
+ ISSUE_TYPES = [Issue.name, WorkItem.name].freeze
TARGET_TYPES = HashWithIndifferentAccess.new(
issue: Issue,
@@ -83,6 +84,7 @@ class Event < ApplicationRecord
scope :recent, -> { reorder(id: :desc) }
scope :for_wiki_page, -> { where(target_type: 'WikiPage::Meta') }
scope :for_design, -> { where(target_type: 'DesignManagement::Design') }
+ scope :for_issue, -> { where(target_type: ISSUE_TYPES) }
scope :for_fingerprint, ->(fingerprint) do
fingerprint.present? ? where(fingerprint: fingerprint) : none
end
diff --git a/app/models/group.rb b/app/models/group.rb
index 0cdd7dd8596..c7ad4d61ddb 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -30,6 +30,8 @@ class Group < Namespace
has_many :all_group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent
has_many :group_members, -> { where(requested_at: nil).where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
+ has_many :namespace_members, -> { where(requested_at: nil).where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }).unscope(where: %i[source_id source_type]) },
+ foreign_key: :member_namespace_id, inverse_of: :group, class_name: 'GroupMember'
alias_method :members, :group_members
has_many :users, through: :group_members
@@ -39,6 +41,8 @@ class Group < Namespace
source: :user
has_many :requesters, -> { where.not(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent
+ has_many :namespace_requesters, -> { where.not(requested_at: nil).unscope(where: %i[source_id source_type]) },
+ foreign_key: :member_namespace_id, inverse_of: :group, class_name: 'GroupMember'
has_many :members_and_requesters, as: :source, class_name: 'GroupMember'
has_many :milestones
@@ -815,7 +819,7 @@ class Group < Namespace
case state
when SR_DISABLED_AND_UNOVERRIDABLE then disable_shared_runners! # also disallows override
- when SR_DISABLED_WITH_OVERRIDE then disable_shared_runners_and_allow_override!
+ when SR_DISABLED_WITH_OVERRIDE, SR_DISABLED_AND_OVERRIDABLE then disable_shared_runners_and_allow_override!
when SR_ENABLED then enable_shared_runners! # set both to true
end
end
@@ -846,7 +850,7 @@ class Group < Namespace
def has_project_with_service_desk_enabled?
Gitlab::ServiceDesk.supported? && all_projects.service_desk_enabled.exists?
end
- strong_memoize_attr :has_project_with_service_desk_enabled?, :has_project_with_service_desk_enabled
+ strong_memoize_attr :has_project_with_service_desk_enabled?
def activity_path
Gitlab::Routing.url_helpers.activity_group_path(self)
@@ -915,6 +919,10 @@ class Group < Namespace
feature_flag_enabled_for_self_or_ancestor?(:work_items_create_from_markdown)
end
+ def usage_quotas_enabled?
+ ::Feature.enabled?(:usage_quotas_for_all_editions, self) && root?
+ end
+
# Check for enabled features, similar to `Project#feature_available?`
# NOTE: We still want to keep this after removing `Namespace#feature_available?`.
override :feature_available?
@@ -1055,7 +1063,7 @@ class Group < Namespace
end
def disable_shared_runners_and_allow_override!
- # enabled -> disabled_with_override
+ # enabled -> disabled_and_overridable
if shared_runners_enabled?
update!(
shared_runners_enabled: false,
@@ -1068,7 +1076,7 @@ class Group < Namespace
all_projects.update_all(shared_runners_enabled: false)
- # disabled_and_unoverridable -> disabled_with_override
+ # disabled_and_unoverridable -> disabled_and_overridable
else
update!(allow_descendants_override_disabled_shared_runners: true)
end
diff --git a/app/models/integration.rb b/app/models/integration.rb
index a630a6dee11..54eeab10360 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -27,7 +27,7 @@ class Integration < ApplicationRecord
# TODO Shimo is temporary disabled on group and instance-levels.
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/345677
PROJECT_SPECIFIC_INTEGRATION_NAMES = %w[
- jenkins shimo
+ apple_app_store jenkins shimo
].freeze
# Fake integrations to help with local development.
@@ -75,6 +75,7 @@ class Integration < ApplicationRecord
attribute :active, default: false
attribute :alert_events, default: true
+ attribute :incident_events, default: false
attribute :category, default: 'common'
attribute :commit_events, default: true
attribute :confidential_issues_events, default: true
@@ -132,6 +133,7 @@ class Integration < ApplicationRecord
scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) }
scope :deployment_hooks, -> { where(deployment_events: true, active: true) }
scope :alert_hooks, -> { where(alert_events: true, active: true) }
+ scope :incident_hooks, -> { where(incident_events: true, active: true) }
scope :deployment, -> { where(category: 'deployment') }
class << self
diff --git a/app/models/integrations/apple_app_store.rb b/app/models/integrations/apple_app_store.rb
new file mode 100644
index 00000000000..84185542939
--- /dev/null
+++ b/app/models/integrations/apple_app_store.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+require 'app_store_connect'
+
+module Integrations
+ class AppleAppStore < Integration
+ ISSUER_ID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/.freeze
+ KEY_ID_REGEX = /\A(?=.*[A-Z])(?=.*[0-9])[A-Z0-9]+\z/.freeze
+
+ with_options if: :activated? do
+ validates :app_store_issuer_id, presence: true, format: { with: ISSUER_ID_REGEX }
+ validates :app_store_key_id, presence: true, format: { with: KEY_ID_REGEX }
+ validates :app_store_private_key, presence: true, certificate_key: true
+ end
+
+ field :app_store_issuer_id,
+ section: SECTION_TYPE_CONNECTION,
+ required: true,
+ title: -> { s_('AppleAppStore|The Apple App Store Connect Issuer ID.') }
+
+ field :app_store_key_id,
+ section: SECTION_TYPE_CONNECTION,
+ required: true,
+ title: -> { s_('AppleAppStore|The Apple App Store Connect Key ID.') },
+ is_secret: false
+
+ field :app_store_private_key,
+ section: SECTION_TYPE_CONNECTION,
+ required: true,
+ type: 'textarea',
+ title: -> { s_('AppleAppStore|The Apple App Store Connect Private Key.') },
+ is_secret: false
+
+ def title
+ 'Apple App Store Connect'
+ end
+
+ def description
+ s_('AppleAppStore|Use GitLab to build and release an app in the Apple App Store.')
+ end
+
+ def help
+ variable_list = [
+ '<code>APP_STORE_CONNECT_API_KEY_ISSUER_ID</code>',
+ '<code>APP_STORE_CONNECT_API_KEY_KEY_ID</code>',
+ '<code>APP_STORE_CONNECT_API_KEY_KEY</code>'
+ ]
+
+ # rubocop:disable Layout/LineLength
+ texts = [
+ s_("Use the Apple App Store Connect integration to easily connect to the Apple App Store with Fastlane in CI/CD pipelines."),
+ s_("After the Apple App Store Connect integration is activated, the following protected variables will be created for CI/CD use."),
+ variable_list.join('<br>'),
+ s_(format("To get started, see the <a href='%{url}' target='_blank'>integration documentation</a> for instructions on how to generate App Store Connect credentials, and how to use this integration.", url: "https://docs.gitlab.com/ee/integration/apple_app_store.html")).html_safe
+ ]
+ # rubocop:enable Layout/LineLength
+
+ texts.join('<br><br>'.html_safe)
+ end
+
+ def self.to_param
+ 'apple_app_store'
+ end
+
+ def self.supported_events
+ []
+ end
+
+ def sections
+ [
+ {
+ type: SECTION_TYPE_CONNECTION,
+ title: s_('Integrations|Integration details'),
+ description: help
+ }
+ ]
+ end
+
+ def test(*_args)
+ response = client.apps
+ if response.has_key?(:errors)
+ { success: false, message: response[:errors].first[:title] }
+ else
+ { success: true }
+ end
+ end
+
+ def ci_variables
+ return [] unless activated?
+
+ [
+ { key: 'APP_STORE_CONNECT_API_KEY_ISSUER_ID', value: app_store_issuer_id, masked: true, public: false },
+ { key: 'APP_STORE_CONNECT_API_KEY_KEY', value: Base64.encode64(app_store_private_key), masked: true,
+ public: false },
+ { key: 'APP_STORE_CONNECT_API_KEY_KEY_ID', value: app_store_key_id, masked: true, public: false }
+ ]
+ end
+
+ private
+
+ def client
+ config = {
+ issuer_id: app_store_issuer_id,
+ key_id: app_store_key_id,
+ private_key: app_store_private_key
+ }
+
+ AppStoreConnect::Client.new(config)
+ end
+ end
+end
diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb
index f2a707c2214..8700b673370 100644
--- a/app/models/integrations/base_chat_notification.rb
+++ b/app/models/integrations/base_chat_notification.rb
@@ -10,7 +10,7 @@ module Integrations
SUPPORTED_EVENTS = %w[
push issue confidential_issue merge_request note confidential_note
- tag_push pipeline wiki_page deployment
+ tag_push pipeline wiki_page deployment incident
].freeze
SUPPORTED_EVENTS_FOR_LABEL_FILTER = %w[issue confidential_issue merge_request note confidential_note].freeze
@@ -76,21 +76,29 @@ module Integrations
def default_fields
[
- { type: 'checkbox', name: 'notify_only_broken_pipelines', help: 'Do not send notifications for successful pipelines.' }.freeze,
+ {
+ type: 'checkbox',
+ section: SECTION_TYPE_CONFIGURATION,
+ name: 'notify_only_broken_pipelines',
+ help: 'Do not send notifications for successful pipelines.'
+ }.freeze,
{
type: 'select',
+ section: SECTION_TYPE_CONFIGURATION,
name: 'branches_to_be_notified',
title: s_('Integrations|Branches for which notifications are to be sent'),
choices: self.class.branch_choices
}.freeze,
{
type: 'text',
+ section: SECTION_TYPE_CONFIGURATION,
name: 'labels_to_be_notified',
placeholder: '~backend,~frontend',
help: 'Send notifications for issue, merge request, and comment events with the listed labels only. Leave blank to receive notifications for all events.'
}.freeze,
{
type: 'select',
+ section: SECTION_TYPE_CONFIGURATION,
name: 'labels_to_be_notified_behavior',
choices: [
['Match any of the labels', MATCH_ANY_LABEL],
@@ -224,6 +232,7 @@ module Integrations
data.merge(project_url: project_url, project_name: project_name).with_indifferent_access
end
+ # rubocop:disable Metrics/CyclomaticComplexity
def get_message(object_kind, data)
case object_kind
when "push", "tag_push"
@@ -240,8 +249,11 @@ module Integrations
Integrations::ChatMessage::WikiPageMessage.new(data)
when "deployment"
Integrations::ChatMessage::DeploymentMessage.new(data) if notify_for_ref?(data)
+ when "incident"
+ Integrations::ChatMessage::IssueMessage.new(data) unless update?(data)
end
end
+ # rubocop:enable Metrics/CyclomaticComplexity
def build_event_channels
event_channel_names.map do |channel_field|
diff --git a/app/models/integrations/base_slash_commands.rb b/app/models/integrations/base_slash_commands.rb
index 11ff7547325..619579a543a 100644
--- a/app/models/integrations/base_slash_commands.rb
+++ b/app/models/integrations/base_slash_commands.rb
@@ -66,7 +66,7 @@ module Integrations
# rubocop: disable CodeReuse/ServiceClass
def authorize_chat_name_url(params)
- ChatNames::AuthorizeUserService.new(self, params).execute
+ ChatNames::AuthorizeUserService.new(params).execute
end
# rubocop: enable CodeReuse/ServiceClass
end
diff --git a/app/models/integrations/chat_message/issue_message.rb b/app/models/integrations/chat_message/issue_message.rb
index ca8ef670e67..1c234630370 100644
--- a/app/models/integrations/chat_message/issue_message.rb
+++ b/app/models/integrations/chat_message/issue_message.rb
@@ -9,6 +9,7 @@ module Integrations
attr_reader :action
attr_reader :state
attr_reader :description
+ attr_reader :object_kind
def initialize(params)
super
@@ -21,6 +22,7 @@ module Integrations
@action = obj_attr[:action]
@state = obj_attr[:state]
@description = obj_attr[:description] || ''
+ @object_kind = params[:object_kind]
end
def attachments
@@ -32,7 +34,7 @@ module Integrations
def activity
{
- title: "Issue #{state} by #{strip_markup(user_combined_name)}",
+ title: "#{issue_type} #{state} by #{strip_markup(user_combined_name)}",
subtitle: "in #{project_link}",
text: issue_link,
image: user_avatar
@@ -42,7 +44,7 @@ module Integrations
private
def message
- "[#{project_link}] Issue #{issue_link} #{state} by #{strip_markup(user_combined_name)}"
+ "[#{project_link}] #{issue_type} #{issue_link} #{state} by #{strip_markup(user_combined_name)}"
end
def opened_issue?
@@ -69,6 +71,10 @@ module Integrations
def issue_title
"#{Issue.reference_prefix}#{issue_iid} #{strip_markup(title)}"
end
+
+ def issue_type
+ @issue_type ||= object_kind == 'incident' ? 'Incident' : 'Issue'
+ end
end
end
end
diff --git a/app/models/integrations/chat_message/pipeline_message.rb b/app/models/integrations/chat_message/pipeline_message.rb
index 88db40bea7f..f8a634be336 100644
--- a/app/models/integrations/chat_message/pipeline_message.rb
+++ b/app/models/integrations/chat_message/pipeline_message.rb
@@ -151,7 +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 << pipeline_name_field if pipeline.name.present?
fields
end
diff --git a/app/models/integrations/field.rb b/app/models/integrations/field.rb
index 53c8f5f623e..329c046075f 100644
--- a/app/models/integrations/field.rb
+++ b/app/models/integrations/field.rb
@@ -4,7 +4,7 @@ module Integrations
class Field
SECRET_NAME = %r/token|key|password|passphrase|secret/.freeze
- BOOLEAN_ATTRIBUTES = %i[required api_only exposes_secrets].freeze
+ BOOLEAN_ATTRIBUTES = %i[required api_only is_secret exposes_secrets].freeze
ATTRIBUTES = %i[
section type placeholder choices value checkbox_label
@@ -17,12 +17,13 @@ module Integrations
attr_reader :name, :integration_class
- def initialize(name:, integration_class:, type: 'text', api_only: false, **attributes)
+ def initialize(name:, integration_class:, type: 'text', is_secret: true, api_only: false, **attributes)
@name = name.to_s.freeze
@integration_class = integration_class
- attributes[:type] = SECRET_NAME.match?(@name) ? 'password' : type
+ attributes[:type] = SECRET_NAME.match?(@name) && is_secret ? 'password' : type
attributes[:api_only] = api_only
+ attributes[:is_secret] = is_secret
@attributes = attributes.freeze
invalid_attributes = attributes.keys - ATTRIBUTES
diff --git a/app/models/integrations/flowdock.rb b/app/models/integrations/flowdock.rb
deleted file mode 100644
index d7625cfb3d2..00000000000
--- a/app/models/integrations/flowdock.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-# This integration is scheduled for removal.
-# All records must be deleted before the class can be removed.
-# https://gitlab.com/gitlab-org/gitlab/-/issues/379197
-module Integrations
- class Flowdock < Integration
- def readonly?
- true
- end
-
- def self.to_param
- 'flowdock'
- end
-
- def self.supported_events
- %w[]
- end
- end
-end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 1dd11ff8315..6744ee230b0 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -46,7 +46,7 @@ class Issue < ApplicationRecord
#
# 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 objective).freeze
+ TYPES_FOR_LIST = %w(issue incident test_case task objective key_result).freeze
# Types of issues that should be displayed on issue board lists
TYPES_FOR_BOARD_LIST = %w(issue incident).freeze
@@ -663,11 +663,6 @@ class Issue < ApplicationRecord
author&.banned?
end
- # Necessary until all issues are backfilled and we add a NOT NULL constraint on the DB
- def work_item_type
- super || WorkItems::Type.default_by_type(issue_type)
- end
-
def expire_etag_cache
key = Gitlab::Routing.url_helpers.realtime_changes_project_issue_path(project, self)
Gitlab::EtagCaching::Store.new.touch(key)
diff --git a/app/models/label_note.rb b/app/models/label_note.rb
index 19dede36abd..eda650f2fa2 100644
--- a/app/models/label_note.rb
+++ b/app/models/label_note.rb
@@ -4,12 +4,19 @@ class LabelNote < SyntheticNote
attr_accessor :resource_parent
attr_reader :events
+ def self.from_event(event, resource: nil, resource_parent: nil)
+ attrs = note_attributes('label', event, resource, resource_parent).merge(events: [event])
+
+ LabelNote.new(attrs)
+ end
+
def self.from_events(events, resource: nil, resource_parent: nil)
resource ||= events.first.issuable
- attrs = note_attributes('label', events.first, resource, resource_parent).merge(events: events)
+ label_note = from_event(events.first, resource: resource, resource_parent: resource_parent)
+ label_note.events = events
- LabelNote.new(attrs)
+ label_note
end
def events=(events)
@@ -37,8 +44,8 @@ class LabelNote < SyntheticNote
end
def note_text(html: false)
- added = labels_str(label_refs_by_action('add', html), prefix: 'added', suffix: added_suffix)
- removed = labels_str(label_refs_by_action('remove', html), prefix: removed_prefix)
+ added = labels_str(label_refs_by_action('add', html).uniq, prefix: 'added', suffix: added_suffix)
+ removed = labels_str(label_refs_by_action('remove', html).uniq, prefix: removed_prefix)
[added, removed].compact.join(' and ')
end
diff --git a/app/models/member.rb b/app/models/member.rb
index 107530daf51..ecf9013f197 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -386,10 +386,10 @@ class Member < ApplicationRecord
user.present?
end
- def accept_request
+ def accept_request(current_user)
return false unless request?
- updated = self.update(requested_at: nil)
+ updated = self.update(requested_at: nil, created_by: current_user)
after_accept_request if updated
updated
@@ -531,7 +531,7 @@ class Member < ApplicationRecord
def send_request
notification_service.new_access_request(self)
- todo_service.create_member_access_request(self) if source_type != 'Project'
+ todo_service.create_member_access_request_todos(self)
end
def post_create_hook
diff --git a/app/models/members/member_role.rb b/app/models/members/member_role.rb
index e9d7b1d3f80..36cbc97d049 100644
--- a/app/models/members/member_role.rb
+++ b/app/models/members/member_role.rb
@@ -11,6 +11,7 @@ class MemberRole < ApplicationRecord # rubocop:disable Gitlab/NamespacedClass
validates :base_access_level, presence: true
validate :belongs_to_top_level_namespace
validate :validate_namespace_locked, on: :update
+ validate :attributes_locked_after_member_associated, on: :update
validates_associated :members
@@ -27,4 +28,11 @@ class MemberRole < ApplicationRecord # rubocop:disable Gitlab/NamespacedClass
errors.add(:namespace, s_("MemberRole|can't be changed"))
end
+
+ def attributes_locked_after_member_associated
+ return unless members.present?
+
+ errors.add(:base, s_("MemberRole|cannot be changed because it is already assigned to a user. "\
+ "Please create a new Member Role instead"))
+ end
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 78c6d983a3d..0012f098ab2 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -193,6 +193,12 @@ class MergeRequest < ApplicationRecord
merge_request.merge_error = nil
end
+ before_transition any => :merged do |merge_request|
+ if ::Feature.enabled?(:reset_merge_error_on_transition, merge_request.project)
+ merge_request.merge_error = nil
+ end
+ end
+
after_transition any => :opened do |merge_request|
merge_request.run_after_commit do
UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
@@ -436,6 +442,14 @@ class MergeRequest < ApplicationRecord
)
end
+ scope :without_hidden, -> {
+ if Feature.enabled?(:hide_merge_requests_from_banned_users)
+ where_not_exists(Users::BannedUser.where('merge_requests.author_id = banned_users.user_id'))
+ else
+ all
+ end
+ }
+
def self.total_time_to_merge
join_metrics
.merge(MergeRequest::Metrics.with_valid_time_to_merge)
@@ -2001,6 +2015,10 @@ class MergeRequest < ApplicationRecord
false # overridden in EE
end
+ def hidden?
+ Feature.enabled?(:hide_merge_requests_from_banned_users) && author&.banned?
+ end
+
private
attr_accessor :skip_fetch_ref
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index cff8911d84b..1395b8ff162 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -392,8 +392,13 @@ class MergeRequestDiff < ApplicationRecord
def diffs_in_batch(batch_page, batch_size, diff_options:)
fetching_repository_diffs(diff_options) do |comparison|
- reorder_diff_files!
- diffs_batch = diffs_in_batch_collection(batch_page, batch_size, diff_options: diff_options)
+ Gitlab::Metrics.measure(:diffs_reorder) do
+ reorder_diff_files!
+ end
+
+ diffs_batch = Gitlab::Metrics.measure(:diffs_collection) do
+ diffs_in_batch_collection(batch_page, batch_size, diff_options: diff_options)
+ end
if comparison
if diff_options[:paths].blank? && !without_files?
@@ -406,7 +411,9 @@ class MergeRequestDiff < ApplicationRecord
)
end
- comparison.diffs(diff_options)
+ Gitlab::Metrics.measure(:diffs_comparison) do
+ comparison.diffs(diff_options)
+ end
else
diffs_batch
end
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index da07d8dd9fc..b0676c25f8e 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -166,8 +166,6 @@ class Milestone < ApplicationRecord
end
def self.states_count(projects, groups = nil)
- return STATE_COUNT_HASH unless projects || groups
-
counts = Milestone
.for_projects_and_groups(projects, groups)
.reorder(nil)
diff --git a/app/models/ml/candidate.rb b/app/models/ml/candidate.rb
index f24161d598f..3ea46a8b703 100644
--- a/app/models/ml/candidate.rb
+++ b/app/models/ml/candidate.rb
@@ -2,6 +2,8 @@
module Ml
class Candidate < ApplicationRecord
+ PACKAGE_PREFIX = 'ml_candidate_'
+
enum status: { running: 0, scheduled: 1, finished: 2, failed: 3, killed: 4 }
validates :iid, :experiment, presence: true
@@ -16,20 +18,31 @@ module Ml
attribute :iid, default: -> { SecureRandom.uuid }
- scope :including_metrics_and_params, -> { includes(:latest_metrics, :params) }
+ scope :including_relationships, -> { includes(:latest_metrics, :params, :user) }
+
+ delegate :project_id, :project, to: :experiment
def artifact_root
"/#{package_name}/#{package_version}/"
end
def artifact
- ::Packages::Generic::PackageFinder.new(experiment.project).execute!(package_name, package_version)
- rescue ActiveRecord::RecordNotFound
- nil
+ artifact_lazy&.itself
+ end
+
+ def artifact_lazy
+ BatchLoader.for(id).batch do |candidate_ids, loader|
+ Packages::Package
+ .joins("INNER JOIN ml_candidates ON packages_packages.name=(concat('#{PACKAGE_PREFIX}', ml_candidates.id))")
+ .where(ml_candidates: { id: candidate_ids })
+ .find_each do |package|
+ loader.call(package.name.delete_prefix(PACKAGE_PREFIX).to_i, package)
+ end
+ end
end
def package_name
- "ml_candidate_#{iid}"
+ "#{PACKAGE_PREFIX}#{id}"
end
def package_version
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index d7d53956656..cf638f9b16c 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -11,17 +11,12 @@ class Namespace < ApplicationRecord
include FeatureGate
include FromUnion
include Gitlab::Utils::StrongMemoize
- include IgnorableColumns
include Namespaces::Traversal::Recursive
include Namespaces::Traversal::Linear
include EachBatch
include BlocksUnsafeSerialization
include Ci::NamespaceSettings
- # Temporary column used for back-filling project namespaces.
- # Remove it once the back-filling of all project namespaces is done.
- ignore_column :tmp_project_id, remove_with: '14.7', remove_after: '2022-01-22'
-
# Tells ActiveRecord not to store the full class name, in order to save some space
# https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69794
self.store_full_sti_class = false
@@ -33,9 +28,11 @@ class Namespace < ApplicationRecord
NUMBER_OF_ANCESTORS_ALLOWED = 20
SR_DISABLED_AND_UNOVERRIDABLE = 'disabled_and_unoverridable'
+ # DISABLED_WITH_OVERRIDE is deprecated in favour of DISABLED_AND_OVERRIDABLE.
SR_DISABLED_WITH_OVERRIDE = 'disabled_with_override'
+ SR_DISABLED_AND_OVERRIDABLE = 'disabled_and_overridable'
SR_ENABLED = 'enabled'
- SHARED_RUNNERS_SETTINGS = [SR_DISABLED_AND_UNOVERRIDABLE, SR_DISABLED_WITH_OVERRIDE, SR_ENABLED].freeze
+ SHARED_RUNNERS_SETTINGS = [SR_DISABLED_AND_UNOVERRIDABLE, SR_DISABLED_WITH_OVERRIDE, SR_DISABLED_AND_OVERRIDABLE, SR_ENABLED].freeze
URL_MAX_LENGTH = 255
PATH_TRAILING_VIOLATIONS = %w[.git .atom .].freeze
@@ -87,6 +84,7 @@ class Namespace < ApplicationRecord
has_many :timelog_categories, class_name: 'TimeTracking::TimelogCategory'
has_many :achievements, class_name: 'Achievements::Achievement'
+ has_many :namespace_commit_emails, class_name: 'Users::NamespaceCommitEmail'
validates :owner, presence: true, if: ->(n) { n.owner_required? }
validates :name,
@@ -134,6 +132,10 @@ class Namespace < ApplicationRecord
to: :namespace_settings
delegate :runner_registration_enabled, :runner_registration_enabled?, :runner_registration_enabled=,
to: :namespace_settings
+ delegate :allow_runner_registration_token,
+ :allow_runner_registration_token?,
+ :allow_runner_registration_token=,
+ to: :namespace_settings
delegate :maven_package_requests_forwarding,
:pypi_package_requests_forwarding,
:npm_package_requests_forwarding,
@@ -556,7 +558,7 @@ class Namespace < ApplicationRecord
if shared_runners_enabled
SR_ENABLED
elsif allow_descendants_override_disabled_shared_runners
- SR_DISABLED_WITH_OVERRIDE
+ SR_DISABLED_AND_OVERRIDABLE
else
SR_DISABLED_AND_UNOVERRIDABLE
end
@@ -566,10 +568,10 @@ class Namespace < ApplicationRecord
case other_setting
when SR_ENABLED
false
- when SR_DISABLED_WITH_OVERRIDE
+ when SR_DISABLED_WITH_OVERRIDE, SR_DISABLED_AND_OVERRIDABLE
shared_runners_setting == SR_ENABLED
when SR_DISABLED_AND_UNOVERRIDABLE
- shared_runners_setting == SR_ENABLED || shared_runners_setting == SR_DISABLED_WITH_OVERRIDE
+ shared_runners_setting == SR_ENABLED || shared_runners_setting == SR_DISABLED_AND_OVERRIDABLE || shared_runners_setting == SR_DISABLED_WITH_OVERRIDE
else
raise ArgumentError
end
diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb
index 5081d5cdafe..7f65fb3a378 100644
--- a/app/models/namespace_setting.rb
+++ b/app/models/namespace_setting.rb
@@ -69,6 +69,12 @@ class NamespaceSetting < ApplicationRecord
!self.class.where(namespace_id: namespace.ancestors, runner_registration_enabled: false).exists?
end
+ def allow_runner_registration_token?
+ settings = Gitlab::CurrentSettings.current_application_settings
+
+ settings.allow_runner_registration_token && namespace.root_ancestor.allow_runner_registration_token
+ end
+
private
def all_ancestors_allow_diff_preview_in_email?
diff --git a/app/models/note.rb b/app/models/note.rb
index 052df6142c5..73c8e72d8b0 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -125,6 +125,7 @@ class Note < ApplicationRecord
scope :for_commit_id, ->(commit_id) { where(noteable_type: "Commit", commit_id: commit_id) }
scope :system, -> { where(system: true) }
scope :user, -> { where(system: false) }
+ scope :not_internal, -> { where(internal: false) }
scope :common, -> { where(noteable_type: ["", nil]) }
scope :fresh, -> { order_created_asc.with_order_id_asc }
scope :updated_after, ->(time) { where('updated_at > ?', time) }
@@ -133,9 +134,16 @@ class Note < ApplicationRecord
scope :inc_author, -> { includes(:author) }
scope :inc_note_diff_file, -> { includes(:note_diff_file) }
scope :with_api_entity_associations, -> { preload(:note_diff_file, :author) }
- scope :inc_relations_for_view, -> do
- includes({ project: :group }, { author: :status }, :updated_by, :resolved_by, :award_emoji,
- { system_note_metadata: :description_version }, :note_diff_file, :diff_note_positions, :suggestions)
+ scope :inc_relations_for_view, ->(noteable = nil) do
+ relations = [{ project: :group }, { author: :status }, :updated_by, :resolved_by,
+ :award_emoji, { system_note_metadata: :description_version }, :suggestions]
+
+ if noteable.nil? || DiffNote.noteable_types.include?(noteable.class.name) ||
+ Feature.disabled?(:skip_notes_diff_include)
+ relations += [:note_diff_file, :diff_note_positions]
+ end
+
+ includes(relations)
end
scope :with_notes_filter, -> (notes_filter) do
diff --git a/app/models/packages/nuget.rb b/app/models/packages/nuget.rb
index 6bedd488c8a..9a9e5b6605a 100644
--- a/app/models/packages/nuget.rb
+++ b/app/models/packages/nuget.rb
@@ -3,6 +3,7 @@ module Packages
module Nuget
TEMPORARY_PACKAGE_NAME = 'NuGet.Temporary.Package'
TEMPORARY_SYMBOL_PACKAGE_NAME = 'NuGet.Temporary.SymbolPackage'
+ FORMAT = 'nupkg'
def self.table_name_prefix
'packages_nuget_'
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index 17c5415939c..966165f9ad7 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -36,6 +36,7 @@ class Packages::Package < ApplicationRecord
# TODO: put the installable default scope on the :package_files association once the dependent: :destroy is removed
# See https://gitlab.com/gitlab-org/gitlab/-/issues/349191
has_many :installable_package_files, -> { installable }, class_name: 'Packages::PackageFile', inverse_of: :package
+ has_many :installable_nuget_package_files, -> { installable.with_nuget_format }, class_name: 'Packages::PackageFile', inverse_of: :package
has_many :dependency_links, inverse_of: :package, class_name: 'Packages::DependencyLink'
has_many :tags, inverse_of: :package, class_name: 'Packages::Tag'
has_one :conan_metadatum, inverse_of: :package, class_name: 'Packages::Conan::Metadatum'
@@ -128,6 +129,7 @@ class Packages::Package < ApplicationRecord
scope :including_project_namespace_route, -> { includes(project: { namespace: :route }) }
scope :including_tags, -> { includes(:tags) }
scope :including_dependency_links, -> { includes(dependency_links: :dependency) }
+ scope :including_dependency_links_with_nuget_metadatum, -> { includes(dependency_links: [:dependency, :nuget_metadatum]) }
scope :with_conan_channel, ->(package_channel) do
joins(:conan_metadatum).where(packages_conan_metadata: { package_channel: package_channel })
@@ -149,12 +151,14 @@ class Packages::Package < ApplicationRecord
end
scope :preload_composer, -> { preload(:composer_metadatum) }
scope :preload_npm_metadatum, -> { preload(:npm_metadatum) }
+ scope :preload_nuget_metadatum, -> { preload(:nuget_metadatum) }
scope :preload_pypi_metadatum, -> { preload(:pypi_metadatum) }
scope :without_nuget_temporary_name, -> { where.not(name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) }
scope :has_version, -> { where.not(version: nil) }
scope :preload_files, -> { preload(:installable_package_files) }
+ scope :preload_nuget_files, -> { preload(:installable_nuget_package_files) }
scope :preload_pipelines, -> { preload(pipelines: :user) }
scope :last_of_each_version, -> { where(id: all.select('MAX(id) AS id').group(:version)) }
scope :limit_recent, ->(limit) { order_created_desc.limit(limit) }
diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb
index 3d56c563ec8..e1486c11298 100644
--- a/app/models/packages/package_file.rb
+++ b/app/models/packages/package_file.rb
@@ -44,6 +44,7 @@ class Packages::PackageFile < ApplicationRecord
scope :with_file_name_like, ->(file_name) { where(arel_table[:file_name].matches(file_name)) }
scope :with_files_stored_locally, -> { where(file_store: ::Packages::PackageFileUploader::Store::LOCAL) }
scope :with_format, ->(format) { where(::Packages::PackageFile.arel_table[:file_name].matches("%.#{format}")) }
+ scope :with_nuget_format, -> { with_format(Packages::Nuget::FORMAT) }
scope :preload_package, -> { preload(:package) }
scope :preload_pipelines, -> { preload(pipelines: :user) }
diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb
index cf0f0f9e92f..a1ba48f3ab0 100644
--- a/app/models/pages/lookup_path.rb
+++ b/app/models/pages/lookup_path.rb
@@ -46,7 +46,7 @@ module Pages
strong_memoize_attr :source
def prefix
- if project.pages_group_root?
+ if project.pages_namespace_url == project.pages_url
'/'
else
project.full_path.delete_prefix(trim_prefix) + '/'
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 4e3f4b0c328..909658214fd 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -78,6 +78,10 @@ class PagesDomain < ApplicationRecord
find_by("LOWER(domain) = LOWER(?)", domain)
end
+ def self.ids_for_project(project_id)
+ where(project_id: project_id).ids
+ end
+
def verified?
!!verified_at
end
@@ -209,7 +213,7 @@ class PagesDomain < ApplicationRecord
return unless pages_deployed?
cache = if Feature.enabled?(:cache_pages_domain_api, project.root_namespace)
- ::Gitlab::Pages::CacheControl.for_project(project.id)
+ ::Gitlab::Pages::CacheControl.for_domain(id)
end
Pages::VirtualDomain.new(
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index 887ef36cc17..0da205f86a5 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -21,6 +21,11 @@ class PersonalAccessToken < ApplicationRecord
after_initialize :set_default_scopes, if: :persisted?
before_save :ensure_token
+ # During the implementation of Admin Mode for API, tokens of
+ # administrators should automatically get the `admin_mode` scope as well
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/42692
+ before_create :add_admin_mode_scope, if: :user_admin?
+
scope :active, -> { not_revoked.not_expired }
scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND expires_at >= CURRENT_DATE AND expires_at <= ?", date]) }
scope :expired_today_and_not_notified, -> { where(["revoked = false AND expires_at = CURRENT_DATE AND after_expiry_notification_delivered = false"]) }
@@ -79,7 +84,12 @@ class PersonalAccessToken < ApplicationRecord
protected
def validate_scopes
- unless revoked || scopes.all? { |scope| Gitlab::Auth.all_available_scopes.include?(scope.to_sym) }
+ # During the implementation of Admin Mode for API,
+ # the `admin_mode` scope is not yet part of `all_available_scopes` but still valid.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/42692
+ valid_scopes = Gitlab::Auth.all_available_scopes + [Gitlab::Auth::ADMIN_MODE_SCOPE]
+
+ unless revoked || scopes.all? { |scope| valid_scopes.include?(scope.to_sym) }
errors.add :scopes, "can only contain available scopes"
end
end
@@ -91,6 +101,14 @@ class PersonalAccessToken < ApplicationRecord
self.scopes = Gitlab::Auth::DEFAULT_SCOPES if self.scopes.empty?
end
+
+ def user_admin?
+ user.admin? # rubocop: disable Cop/UserAdmin
+ end
+
+ def add_admin_mode_scope
+ self.scopes += [Gitlab::Auth::ADMIN_MODE_SCOPE.to_s]
+ end
end
PersonalAccessToken.prepend_mod_with('PersonalAccessToken')
diff --git a/app/models/project.rb b/app/models/project.rb
index 73dbb55a07b..561a842f23a 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -170,6 +170,7 @@ class Project < ApplicationRecord
end
# Project integrations
+ has_one :apple_app_store_integration, class_name: 'Integrations::AppleAppStore'
has_one :asana_integration, class_name: 'Integrations::Asana'
has_one :assembla_integration, class_name: 'Integrations::Assembla'
has_one :bamboo_integration, class_name: 'Integrations::Bamboo'
@@ -269,6 +270,7 @@ class Project < ApplicationRecord
has_many :integrations
has_many :alert_hooks_integrations, -> { alert_hooks }, class_name: 'Integration'
+ has_many :incident_hooks_integrations, -> { incident_hooks }, class_name: 'Integration'
has_many :archive_trace_hooks_integrations, -> { archive_trace_hooks }, class_name: 'Integration'
has_many :confidential_issue_hooks_integrations, -> { confidential_issue_hooks }, class_name: 'Integration'
has_many :confidential_note_hooks_integrations, -> { confidential_note_hooks }, class_name: 'Integration'
@@ -291,18 +293,24 @@ class Project < ApplicationRecord
has_many :project_authorizations
has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User'
+
has_many :project_members, -> { where(requested_at: nil) },
as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
-
- has_many :project_callouts, class_name: 'Users::ProjectCallout', foreign_key: :project_id
-
alias_method :members, :project_members
- has_many :users, through: :project_members
+ has_many :namespace_members, ->(project) { where(requested_at: nil).unscope(where: %i[source_id source_type]) },
+ primary_key: :project_namespace_id, foreign_key: :member_namespace_id, inverse_of: :project, class_name: 'ProjectMember'
has_many :requesters, -> { where.not(requested_at: nil) },
as: :source, class_name: 'ProjectMember', dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+ has_many :namespace_requesters, ->(project) { where.not(requested_at: nil).unscope(where: %i[source_id source_type]) },
+ primary_key: :project_namespace_id, foreign_key: :member_namespace_id, inverse_of: :project, class_name: 'ProjectMember'
+
has_many :members_and_requesters, as: :source, class_name: 'ProjectMember'
+ has_many :users, through: :project_members
+
+ has_many :project_callouts, class_name: 'Users::ProjectCallout', foreign_key: :project_id
+
has_many :deploy_keys_projects, inverse_of: :project
has_many :deploy_keys, through: :deploy_keys_projects
has_many :users_star_projects
@@ -750,16 +758,13 @@ class Project < ApplicationRecord
end
end
- # Defines instance methods:
+ # Define two instance methods:
#
- # - only_allow_merge_if_pipeline_succeeds?(inherit_group_setting: false)
- # - allow_merge_on_skipped_pipeline?(inherit_group_setting: false)
- # - only_allow_merge_if_all_discussions_are_resolved?(inherit_group_setting: false)
- # - only_allow_merge_if_pipeline_succeeds_locked?
- # - allow_merge_on_skipped_pipeline_locked?
- # - only_allow_merge_if_all_discussions_are_resolved_locked?
+ # - [attribute]?(inherit_group_setting) Returns the final value after inheriting the parent group
+ # - [attribute]_locked? Returns true if the value is inherited from the parent group
+ #
+ # These functions will be overridden in EE to make sense afterwards
def self.cascading_with_parent_namespace(attribute)
- # method overriden in EE
define_method("#{attribute}?") do |inherit_group_setting: false|
self.public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend
end
@@ -1610,7 +1615,9 @@ class Project < ApplicationRecord
end
def disabled_integrations
- []
+ disabled_integrations = []
+ disabled_integrations << 'apple_app_store' unless Feature.enabled?(:apple_app_store_integration, self)
+ disabled_integrations
end
def find_or_initialize_integration(name)
@@ -1722,14 +1729,8 @@ class Project < ApplicationRecord
def execute_integrations(data, hooks_scope = :push_hooks)
# Call only service hooks that are active for this scope
run_after_commit_or_now do
- if use_integration_relations?
- association("#{hooks_scope}_integrations").reader.each do |integration|
- integration.async_execute(data)
- end
- else
- integrations.public_send(hooks_scope).each do |integration| # rubocop:disable GitlabSecurity/PublicSend
- integration.async_execute(data)
- end
+ association("#{hooks_scope}_integrations").reader.each do |integration|
+ integration.async_execute(data)
end
end
end
@@ -2100,7 +2101,7 @@ class Project < ApplicationRecord
pages_metadatum&.deployed?
end
- def pages_group_url
+ def pages_namespace_url
# The host in URL always needs to be downcased
Gitlab.config.pages.url.sub(%r{^https?://}) do |prefix|
"#{prefix}#{pages_subdomain}."
@@ -2108,19 +2109,23 @@ class Project < ApplicationRecord
end
def pages_url
- url = pages_group_url
+ url = pages_namespace_url
url_path = full_path.partition('/').last
+ namespace_url = "#{Settings.pages.protocol}://#{url_path}".downcase
+
+ if Rails.env.development?
+ url_without_port = URI.parse(url)
+ url_without_port.port = nil
+
+ return url if url_without_port.to_s == namespace_url
+ end
# If the project path is the same as host, we serve it as group page
- return url if url == "#{Settings.pages.protocol}://#{url_path}".downcase
+ return url if url == namespace_url
"#{url}/#{url_path}"
end
- def pages_group_root?
- pages_group_url == pages_url
- end
-
def pages_subdomain
full_path.partition('/').first
end
@@ -2920,12 +2925,6 @@ class Project < ApplicationRecord
Gitlab::Routing.url_helpers.activity_project_path(self)
end
- def increment_statistic_value(statistic, delta)
- return if pending_delete?
-
- ProjectStatistics.increment_statistic(self, statistic, delta)
- end
-
def ci_forward_deployment_enabled?
return false unless ci_cd_settings
@@ -3369,12 +3368,6 @@ class Project < ApplicationRecord
ProjectFeature::PRIVATE
end
end
-
- def use_integration_relations?
- strong_memoize(:use_integration_relations) do
- Feature.enabled?(:cache_project_integrations, self)
- end
- end
end
Project.prepend_mod_with('Project')
diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb
index 7116ccd9824..db86bb5e1fb 100644
--- a/app/models/project_setting.rb
+++ b/app/models/project_setting.rb
@@ -59,7 +59,7 @@ class ProjectSetting < ApplicationRecord
!!super
end
end
- strong_memoize_attr :show_diff_preview_in_email?, :show_diff_preview_in_email
+ strong_memoize_attr :show_diff_preview_in_email?
private
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index 506f6305791..732dadc03d9 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -123,16 +123,37 @@ class ProjectStatistics < ApplicationRecord
# through counter_attribute_after_commit
#
# For non-counter attributes, storage_size is updated depending on key => [columns] in INCREMENTABLE_COLUMNS
- def self.increment_statistic(project, key, amount)
+ def self.increment_statistic(project, key, increment)
+ return if project.pending_delete?
+
+ project.statistics.try do |project_statistics|
+ project_statistics.increment_statistic(key, increment)
+ end
+ end
+
+ def self.bulk_increment_statistic(project, key, increments)
+ unless Feature.enabled?(:project_statistics_bulk_increment, type: :development)
+ total_amount = Gitlab::Counters::Increment.new(amount: increments.sum(&:amount))
+ return increment_statistic(project, key, total_amount)
+ end
+
+ return if project.pending_delete?
+
project.statistics.try do |project_statistics|
- project_statistics.increment_statistic(key, amount)
+ project_statistics.bulk_increment_statistic(key, increments)
end
end
- def increment_statistic(key, amount)
+ def increment_statistic(key, increment)
+ raise ArgumentError, "Cannot increment attribute: #{key}" unless incrementable_attribute?(key)
+
+ increment_counter(key, increment)
+ end
+
+ def bulk_increment_statistic(key, increments)
raise ArgumentError, "Cannot increment attribute: #{key}" unless incrementable_attribute?(key)
- increment_counter(key, amount)
+ bulk_increment_counter(key, increments)
end
private
diff --git a/app/models/projects/branch_rule.rb b/app/models/projects/branch_rule.rb
new file mode 100644
index 00000000000..ae59d24e557
--- /dev/null
+++ b/app/models/projects/branch_rule.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Projects
+ class BranchRule
+ extend Forwardable
+
+ attr_reader :project, :protected_branch
+
+ def_delegators(:protected_branch, :name, :group, :default_branch?, :created_at, :updated_at)
+
+ def initialize(project, protected_branch)
+ @protected_branch = protected_branch
+ @project = project
+ end
+
+ def protected?
+ true
+ end
+
+ def matching_branches_count
+ branch_names = project.repository.branch_names
+ matching_branches = protected_branch.matching(branch_names)
+ matching_branches.count
+ end
+
+ def branch_protection
+ protected_branch
+ end
+ end
+end
+
+Projects::BranchRule.prepend_mod
diff --git a/app/models/projects/build_artifacts_size_refresh.rb b/app/models/projects/build_artifacts_size_refresh.rb
index 2ffc7478178..b791cb1254c 100644
--- a/app/models/projects/build_artifacts_size_refresh.rb
+++ b/app/models/projects/build_artifacts_size_refresh.rb
@@ -7,16 +7,34 @@ module Projects
STALE_WINDOW = 2.hours
+ # This delay is set to 10 minutes to accommodate any ongoing
+ # deletion that might have happened.
+ # The delete on the database may have been committed before
+ # the refresh completed its batching. If the resulting decrement is
+ # pushed into Redis after the refresh has ended, it would result in net negative value.
+ # The delay is needed to ensure this negative value is ignored.
+ FINALIZE_DELAY = 10.minutes
+
self.table_name = 'project_build_artifacts_size_refreshes'
+ COUNTER_ATTRIBUTE_NAME = :build_artifacts_size
+
belongs_to :project
validates :project, presence: true
+ # The refresh of the project statistics counter is performed in 4 stages:
+ # 1. created - The refresh is on the queue to be processed by Projects::RefreshBuildArtifactsSizeStatisticsWorker
+ # 2. running - The refresh is ongoing. The project statistics counter switches to the temporary refresh counter key.
+ # Counter increments are deduplicated.
+ # 3. pending - The refresh is pending to be picked up by Projects::RefreshBuildArtifactsSizeStatisticsWorker again.
+ # 4. finalizing - The refresh has finished summing existing job artifact size into the refresh counter key.
+ # The sum will need to be moved into the counter key.
STATES = {
created: 1,
running: 2,
- pending: 3
+ pending: 3,
+ finalizing: 4
}.freeze
state_machine :state, initial: :created do
@@ -24,6 +42,7 @@ module Projects
state :created, value: STATES[:created]
state :running, value: STATES[:running]
state :pending, value: STATES[:pending]
+ state :finalizing, value: STATES[:finalizing]
event :process do
transition [:created, :pending, :running] => :running
@@ -33,7 +52,10 @@ module Projects
transition running: :pending
end
- # set it only the first time we execute the refresh
+ event :schedule_finalize do
+ transition running: :finalizing
+ end
+
before_transition created: :running do |refresh|
refresh.reset_project_statistics!
refresh.refresh_started_at = Time.zone.now
@@ -47,6 +69,10 @@ module Projects
before_transition running: :pending do |refresh, transition|
refresh.last_job_artifact_id = transition.args.first
end
+
+ before_transition running: :finalizing do |refresh, transition|
+ refresh.schedule_finalize_worker
+ end
end
scope :stale, -> { with_state(:running).where('updated_at < ?', STALE_WINDOW.ago) }
@@ -80,7 +106,7 @@ module Projects
end
def reset_project_statistics!
- project.statistics.reset_counter!(:build_artifacts_size)
+ project.statistics.initiate_refresh!(COUNTER_ATTRIBUTE_NAME)
end
def next_batch(limit:)
@@ -95,6 +121,18 @@ module Projects
!created?
end
+ def finalize!
+ project.statistics.finalize_refresh(COUNTER_ATTRIBUTE_NAME)
+
+ destroy!
+ end
+
+ def schedule_finalize_worker
+ run_after_commit do
+ Projects::FinalizeProjectStatisticsRefreshWorker.perform_in(FINALIZE_DELAY, self.class.to_s, id)
+ end
+ end
+
private
def schedule_namespace_aggregation_worker
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index c59ef4cd80b..050db3b6870 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -8,11 +8,9 @@ class ProtectedBranch < ApplicationRecord
validate :validate_either_project_or_top_group
- scope :requiring_code_owner_approval,
- -> { where(code_owner_approval_required: true) }
-
- scope :allowing_force_push,
- -> { where(allow_force_push: true) }
+ scope :requiring_code_owner_approval, -> { where(code_owner_approval_required: true) }
+ scope :allowing_force_push, -> { where(allow_force_push: true) }
+ scope :sorted_by_name, -> { order(name: :asc) }
protected_ref_access_levels :merge, :push
@@ -106,6 +104,10 @@ class ProtectedBranch < ApplicationRecord
name == project.default_branch
end
+ def entity
+ group || project
+ end
+
private
def validate_either_project_or_top_group
@@ -113,7 +115,7 @@ class ProtectedBranch < ApplicationRecord
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
+ elsif group && group.subgroup?
errors.add(:base, _('cannot be associated with a subgroup'))
end
end
diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb
index df75c557717..76e620aa3bf 100644
--- a/app/models/protected_branch/merge_access_level.rb
+++ b/app/models/protected_branch/merge_access_level.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class ProtectedBranch::MergeAccessLevel < ApplicationRecord
+ include Importable
include ProtectedBranchAccess
# default value for the access_level column
GITLAB_DEFAULT_ACCESS_LEVEL = Gitlab::Access::MAINTAINER
diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb
index 6076fab20b7..66fe57be25f 100644
--- a/app/models/protected_branch/push_access_level.rb
+++ b/app/models/protected_branch/push_access_level.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class ProtectedBranch::PushAccessLevel < ApplicationRecord
+ include Importable
include ProtectedBranchAccess
# default value for the access_level column
GITLAB_DEFAULT_ACCESS_LEVEL = Gitlab::Access::MAINTAINER
diff --git a/app/models/protected_tag/create_access_level.rb b/app/models/protected_tag/create_access_level.rb
index 9fcfa7646a2..5d8b1fb4f71 100644
--- a/app/models/protected_tag/create_access_level.rb
+++ b/app/models/protected_tag/create_access_level.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class ProtectedTag::CreateAccessLevel < ApplicationRecord
+ include Importable
include ProtectedTagAccess
def check_access(user)
diff --git a/app/models/release.rb b/app/models/release.rb
index 5ef3ff1bc6c..b770f3934ef 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -26,12 +26,13 @@ class Release < ApplicationRecord
before_create :set_released_at
validates :project, :tag, presence: true
+ validates :author_id, presence: true, if: :validate_release_with_author?
+
validates :tag, uniqueness: { scope: :project_id }
validates :description, length: { maximum: Gitlab::Database::MAX_TEXT_SIZE_LIMIT }, if: :description_changed?
validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") }
validates :links, nested_attributes_duplicates: { scope: :release, child_attributes: %i[name url filepath] }
- validates :author_id, presence: true, on: :create, if: :validate_release_with_author?
scope :sorted, -> { order(released_at: :desc) }
scope :preloaded, -> {
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 90e87de4a5b..cedfed16b20 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -189,6 +189,8 @@ class Repository
return []
end
+ query = Feature.enabled?(:commit_search_trailing_spaces) ? query.strip : query
+
commits = raw_repository.find_commits_by_message(query, ref, path, limit, offset).map do |c|
commit(c)
end
@@ -631,7 +633,11 @@ class Repository
end
def readme_path
- head_tree&.readme_path
+ if Feature.enabled?(:readme_from_gitaly)
+ readme_path_gitaly
+ else
+ head_tree&.readme_path
+ end
end
cache_method :readme_path
@@ -1239,6 +1245,29 @@ class Repository
container.full_path,
container: container)
end
+
+ def readme_path_gitaly
+ return if empty? || root_ref.nil?
+
+ # (?i) to enable case-insensitive mode
+ #
+ # Note: `Gitlab::FileDetector::PATTERNS[:readme]#to_s` won't work because of
+ # incompatibility of regex engines between Rails and Gitaly.
+ regex = "(?i)#{Gitlab::FileDetector::PATTERNS[:readme].source}"
+
+ readmes = search_files_by_regexp(regex, root_ref)
+
+ choose_readme_to_display(readmes)
+ end
+
+ # Extracted from Tree#readme_path
+ def choose_readme_to_display(readmes)
+ previewable_readme = readmes.find { |name| Gitlab::MarkupHelper.previewable?(name) }
+
+ return previewable_readme if previewable_readme
+
+ readmes.find { |name| Gitlab::MarkupHelper.plain?(name) }
+ end
end
Repository.prepend_mod_with('Repository')
diff --git a/app/models/resource_event.rb b/app/models/resource_event.rb
index 8b82e0f343c..551ea984132 100644
--- a/app/models/resource_event.rb
+++ b/app/models/resource_event.rb
@@ -3,6 +3,8 @@
class ResourceEvent < ApplicationRecord
include Gitlab::Utils::StrongMemoize
include Importable
+ include IssueResourceEvent
+ include WorkItemResourceEvent
self.abstract_class = true
@@ -18,6 +20,10 @@ class ResourceEvent < ApplicationRecord
end
end
+ def issuable
+ raise NoMethodError, 'must implement `issuable` method'
+ end
+
private
def discussion_id_key
diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb
index a1426540cf5..efffc1bd6dc 100644
--- a/app/models/resource_label_event.rb
+++ b/app/models/resource_label_event.rb
@@ -2,7 +2,6 @@
class ResourceLabelEvent < ResourceEvent
include CacheMarkdownField
- include IssueResourceEvent
include MergeRequestResourceEvent
cache_markdown_field :reference
@@ -39,6 +38,10 @@ class ResourceLabelEvent < ResourceEvent
issue || merge_request
end
+ def synthetic_note_class
+ LabelNote
+ end
+
def project
issuable.project
end
diff --git a/app/models/resource_milestone_event.rb b/app/models/resource_milestone_event.rb
index 5fd71612de0..def7e91af3f 100644
--- a/app/models/resource_milestone_event.rb
+++ b/app/models/resource_milestone_event.rb
@@ -19,4 +19,8 @@ class ResourceMilestoneEvent < ResourceTimeboxEvent
def milestone_parent
milestone&.parent
end
+
+ def synthetic_note_class
+ MilestoneNote
+ end
end
diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb
index 6ebb9d5f176..134f71e35ad 100644
--- a/app/models/resource_state_event.rb
+++ b/app/models/resource_state_event.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class ResourceStateEvent < ResourceEvent
- include IssueResourceEvent
include MergeRequestResourceEvent
include Importable
@@ -26,6 +25,10 @@ class ResourceStateEvent < ResourceEvent
issue_id.present?
end
+ def synthetic_note_class
+ StateNote
+ end
+
private
def issue_usage_metrics
diff --git a/app/models/resource_timebox_event.rb b/app/models/resource_timebox_event.rb
index 26bf2a225d4..dddd4d0fe84 100644
--- a/app/models/resource_timebox_event.rb
+++ b/app/models/resource_timebox_event.rb
@@ -1,12 +1,11 @@
# frozen_string_literal: true
class ResourceTimeboxEvent < ResourceEvent
- self.abstract_class = true
-
- include IssueResourceEvent
include MergeRequestResourceEvent
include Importable
+ self.abstract_class = true
+
validate :exactly_one_issuable, unless: :importing?
enum action: {
diff --git a/app/models/synthetic_note.rb b/app/models/synthetic_note.rb
index a60c0d2f3bc..f88fa052665 100644
--- a/app/models/synthetic_note.rb
+++ b/app/models/synthetic_note.rb
@@ -14,7 +14,7 @@ class SyntheticNote < Note
discussion_id: event.discussion_id,
noteable: resource,
event: event,
- system_note_metadata: ::SystemNoteMetadata.new(action: action),
+ system_note_metadata: ::SystemNoteMetadata.new(action: action, id: event.discussion_id),
resource_parent: resource_parent
}
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index 4e86036952b..36166bdbc9a 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -34,6 +34,12 @@ class SystemNoteMetadata < ApplicationRecord
belongs_to :note
belongs_to :description_version
+ delegate_missing_to :note
+
+ def declarative_policy_delegate
+ note
+ end
+
def icon_types
ICON_TYPES
end
diff --git a/app/models/timelog.rb b/app/models/timelog.rb
index 7c394736560..07c61f64f29 100644
--- a/app/models/timelog.rb
+++ b/app/models/timelog.rb
@@ -35,10 +35,21 @@ class Timelog < ApplicationRecord
where('spent_at <= ?', end_time)
end
+ scope :order_scope_asc, ->(field) { order(arel_table[field].asc.nulls_last) }
+ scope :order_scope_desc, ->(field) { order(arel_table[field].desc.nulls_last) }
+
def issuable
issue || merge_request
end
+ def self.sort_by_field(field, direction)
+ if direction == :asc
+ order_scope_asc(field)
+ else
+ order_scope_desc(field)
+ end
+ end
+
private
def issuable_id_is_present
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 32ec4accb4b..7bbdf321269 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -204,10 +204,18 @@ class Todo < ApplicationRecord
action == MEMBER_ACCESS_REQUESTED
end
- def access_request_url
- return "" unless self.target_type == 'Namespace'
+ def member_access_type
+ target.class.name.downcase
+ end
- Gitlab::Routing.url_helpers.group_group_members_url(self.target, tab: 'access_requests')
+ def access_request_url(only_path: false)
+ if target.instance_of? Group
+ Gitlab::Routing.url_helpers.group_group_members_url(self.target, tab: 'access_requests', only_path: only_path)
+ elsif target.instance_of? Project
+ Gitlab::Routing.url_helpers.project_project_members_url(self.target, tab: 'access_requests', only_path: only_path)
+ else
+ ""
+ end
end
def done?
diff --git a/app/models/user.rb b/app/models/user.rb
index ba3f7922c9c..da6e1abad07 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -63,12 +63,13 @@ class User < ApplicationRecord
attribute :admin, default: false
attribute :external, default: -> { Gitlab::CurrentSettings.user_default_external }
attribute :can_create_group, default: -> { Gitlab::CurrentSettings.can_create_group }
+ attribute :private_profile, default: -> { Gitlab::CurrentSettings.user_defaults_to_private_profile }
attribute :can_create_team, default: false
attribute :hide_no_ssh_key, default: false
attribute :hide_no_password, default: false
attribute :project_view, default: :files
attribute :notified_of_own_activity, default: false
- attribute :preferred_language, default: -> { I18n.default_locale }
+ attribute :preferred_language, default: -> { Gitlab::CurrentSettings.default_preferred_language }
attribute :theme_id, default: -> { gitlab_config.default_theme }
attr_encrypted :otp_secret,
@@ -100,6 +101,8 @@ class User < ApplicationRecord
MINIMUM_DAYS_CREATED = 7
+ ignore_columns %i[linkedin twitter skype website_url location organization], remove_with: '15.8', remove_after: '2023-01-22'
+
# Override Devise::Models::Trackable#update_tracked_fields!
# to limit database writes to at most once every hour
# rubocop: disable CodeReuse/ServiceClass
@@ -214,7 +217,7 @@ class User < ApplicationRecord
has_many :releases, dependent: :nullify, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :subscriptions, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_one :abuse_report, dependent: :destroy, foreign_key: :user_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :abuse_reports, dependent: :destroy, foreign_key: :user_id # rubocop:disable Cop/ActiveRecordDependent
has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport" # rubocop:disable Cop/ActiveRecordDependent
has_many :spam_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :builds, class_name: 'Ci::Build'
@@ -262,8 +265,11 @@ class User < ApplicationRecord
has_many :resource_label_events, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
has_many :resource_state_events, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
has_many :authored_events, class_name: 'Event', dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
-
- has_many :namespace_commit_emails
+ has_many :namespace_commit_emails, class_name: 'Users::NamespaceCommitEmail'
+ has_many :user_achievements, class_name: 'Achievements::UserAchievement', inverse_of: :user
+ has_many :awarded_user_achievements, class_name: 'Achievements::UserAchievement', foreign_key: 'awarded_by_user_id', inverse_of: :awarded_by_user
+ has_many :revoked_user_achievements, class_name: 'Achievements::UserAchievement', foreign_key: 'revoked_by_user_id', inverse_of: :revoked_by_user
+ has_many :achievements, through: :user_achievements, class_name: 'Achievements::Achievement', inverse_of: :users
#
# Validations
@@ -298,19 +304,15 @@ class User < ApplicationRecord
validates :color_scheme_id, allow_nil: true, inclusion: { in: Gitlab::ColorSchemes.valid_ids,
message: ->(*) { _("%{placeholder} is not a valid color scheme") % { placeholder: '%{value}' } } }
- validates :website_url, allow_blank: true, url: true, if: :website_url_changed?
-
after_initialize :set_projects_limit
before_validation :sanitize_attrs
before_validation :ensure_namespace_correct
after_validation :set_username_errors
- before_save :default_private_profile_to_false
before_save :ensure_incoming_email_token
before_save :ensure_user_rights_and_limits, if: ->(user) { user.new_record? || user.external_changed? }
before_save :skip_reconfirmation!, if: ->(user) { user.email_changed? && user.read_only_attribute?(:email) }
before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? }
before_save :ensure_namespace_correct # in case validation is skipped
- before_save :ensure_user_detail_assigned
after_update :username_changed_hook, if: :saved_change_to_username?
after_destroy :post_destroy_hook
after_destroy :remove_key_cache
@@ -372,6 +374,12 @@ class User < ApplicationRecord
delegate :pronunciation, :pronunciation=, to: :user_detail, allow_nil: true
delegate :registration_objective, :registration_objective=, to: :user_detail, allow_nil: true
delegate :requires_credit_card_verification, :requires_credit_card_verification=, to: :user_detail, allow_nil: true
+ delegate :linkedin, :linkedin=, to: :user_detail, allow_nil: true
+ delegate :twitter, :twitter=, to: :user_detail, allow_nil: true
+ delegate :skype, :skype=, to: :user_detail, allow_nil: true
+ delegate :website_url, :website_url=, to: :user_detail, allow_nil: true
+ delegate :location, :location=, to: :user_detail, allow_nil: true
+ delegate :organization, :organization=, to: :user_detail, allow_nil: true
accepts_nested_attributes_for :user_preference, update_only: true
accepts_nested_attributes_for :user_detail, update_only: true
@@ -531,9 +539,7 @@ class User < ApplicationRecord
strip_attributes! :name
def preferred_language
- read_attribute('preferred_language') ||
- I18n.default_locale.to_s.presence_in(Gitlab::I18n.available_locales) ||
- default_preferred_language
+ read_attribute('preferred_language').presence || Gitlab::CurrentSettings.default_preferred_language
end
def active_for_authentication?
@@ -1401,17 +1407,9 @@ class User < ApplicationRecord
end
def sanitize_attrs
- sanitize_links
sanitize_name
end
- def sanitize_links
- %i[skype linkedin twitter].each do |attr|
- value = self[attr]
- self[attr] = Sanitize.clean(value) if value.present?
- end
- end
-
def sanitize_name
return unless self.name
@@ -1595,11 +1593,6 @@ class User < ApplicationRecord
end
end
- # Temporary, will be removed when user_detail fields are fully migrated
- def ensure_user_detail_assigned
- user_detail.assign_changed_fields_from_user if UserDetail.user_fields_changed?(self)
- end
-
def set_username_errors
namespace_path_errors = self.errors.delete(:"namespace.path")
@@ -1890,7 +1883,7 @@ class User < ApplicationRecord
def invalidate_issue_cache_counts
Rails.cache.delete(['users', id, 'assigned_open_issues_count'])
- Rails.cache.delete(['users', id, 'max_assigned_open_issues_count']) if Feature.enabled?(:limit_assigned_issues_count)
+ Rails.cache.delete(['users', id, 'max_assigned_open_issues_count'])
end
def invalidate_merge_request_cache_counts
@@ -2189,6 +2182,13 @@ class User < ApplicationRecord
public_email.presence || _('[REDACTED]')
end
+ def namespace_commit_email_for_project(project)
+ return if project.nil?
+
+ namespace_commit_emails.find_by(namespace: project.project_namespace) ||
+ namespace_commit_emails.find_by(namespace: project.root_namespace)
+ end
+
protected
# override, from Devise::Validatable
@@ -2230,11 +2230,6 @@ class User < ApplicationRecord
otp_backup_codes.first.start_with?("$pbkdf2-sha512$")
end
- # To enable JiHu repository to modify the default language options
- def default_preferred_language
- 'en'
- end
-
# rubocop: disable CodeReuse/ServiceClass
def add_primary_email_to_emails!
Emails::CreateService.new(self, user: self, email: self.email).execute(confirmed_at: self.confirmed_at)
@@ -2299,12 +2294,6 @@ class User < ApplicationRecord
])
end
- def default_private_profile_to_false
- return unless private_profile_changed? && private_profile.nil?
-
- self.private_profile = false
- end
-
def has_current_license?
false
end
diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb
index 559e93be360..4ebb8ba9f00 100644
--- a/app/models/user_custom_attribute.rb
+++ b/app/models/user_custom_attribute.rb
@@ -11,6 +11,9 @@ class UserCustomAttribute < ApplicationRecord
scope :by_updated_at, ->(updated_at) { where(updated_at: updated_at) }
scope :arkose_sessions, -> { by_key('arkose_session') }
+ BLOCKED_BY = 'blocked_by'
+ UNBLOCKED_BY = 'unblocked_by'
+
class << self
def upsert_custom_attributes(custom_attributes)
created_at = DateTime.now
diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb
index 0570bc2f395..b6765cb0285 100644
--- a/app/models/user_detail.rb
+++ b/app/models/user_detail.rb
@@ -22,14 +22,10 @@ class UserDetail < ApplicationRecord
validates :website_url, length: { maximum: DEFAULT_FIELD_LENGTH }, url: true, allow_blank: true, if: :website_url_changed?
before_validation :sanitize_attrs
- before_save :prevent_nil_bio
+ before_save :prevent_nil_fields
enum registration_objective: REGISTRATION_OBJECTIVE_PAIRS, _suffix: true
- def self.user_fields_changed?(user)
- (%w[linkedin skype twitter website_url location organization] & user.changed).any?
- end
-
def sanitize_attrs
%i[linkedin skype twitter website_url].each do |attr|
value = self[attr]
@@ -41,25 +37,16 @@ class UserDetail < ApplicationRecord
end
end
- def assign_changed_fields_from_user
- self.linkedin = trim_field(user.linkedin) if user.linkedin_changed?
- self.twitter = trim_field(user.twitter) if user.twitter_changed?
- self.skype = trim_field(user.skype) if user.skype_changed?
- self.website_url = trim_field(user.website_url) if user.website_url_changed?
- self.location = trim_field(user.location) if user.location_changed?
- self.organization = trim_field(user.organization) if user.organization_changed?
- end
-
private
- def prevent_nil_bio
- self.bio = '' if bio_changed? && bio.nil?
- end
-
- def trim_field(value)
- return '' unless value
-
- value.first(DEFAULT_FIELD_LENGTH)
+ def prevent_nil_fields
+ self.bio = '' if bio.nil?
+ self.linkedin = '' if linkedin.nil?
+ self.twitter = '' if twitter.nil?
+ self.skype = '' if skype.nil?
+ self.location = '' if location.nil?
+ self.organization = '' if organization.nil?
+ self.website_url = '' if website_url.nil?
end
end
diff --git a/app/models/users/namespace_commit_email.rb b/app/models/users/namespace_commit_email.rb
index 4ec02f12717..883b17187ca 100644
--- a/app/models/users/namespace_commit_email.rb
+++ b/app/models/users/namespace_commit_email.rb
@@ -9,6 +9,22 @@ module Users
validates :user, presence: true
validates :namespace, presence: true
validates :email, presence: true
- validates :user_id, uniqueness: { scope: [:namespace_id] }
+ validates :user, uniqueness: { scope: :namespace_id }
+ validate :validate_root_group
+
+ def self.delete_for_namespace(namespace)
+ where(namespace: namespace).delete_all
+ end
+
+ private
+
+ def validate_root_group
+ # Due to the way Rails validations are invoked all at once,
+ # namespace sometimes won't exist when this is ran even though we have a validation for presence first.
+ return unless namespace&.group_namespace?
+ return if namespace.root?
+
+ errors.add(:namespace, _('must be a root group.'))
+ end
end
end
diff --git a/app/models/work_item.rb b/app/models/work_item.rb
index 0810c520f7e..f94e831437a 100644
--- a/app/models/work_item.rb
+++ b/app/models/work_item.rb
@@ -13,6 +13,8 @@ class WorkItem < Issue
has_many :child_links, class_name: '::WorkItems::ParentLink', foreign_key: :work_item_parent_id
has_many :work_item_children, through: :child_links, class_name: 'WorkItem',
foreign_key: :work_item_id, source: :work_item
+ has_many :work_item_children_by_created_at, -> { order(:created_at) }, through: :child_links, class_name: 'WorkItem',
+ foreign_key: :work_item_id, source: :work_item
scope :inc_relations_for_permission_check, -> { includes(:author, project: :project_feature) }
diff --git a/app/models/work_items/parent_link.rb b/app/models/work_items/parent_link.rb
index 33857fb08c2..21e31980fda 100644
--- a/app/models/work_items/parent_link.rb
+++ b/app/models/work_items/parent_link.rb
@@ -2,6 +2,8 @@
module WorkItems
class ParentLink < ApplicationRecord
+ include RelativePositioning
+
self.table_name = 'work_item_parent_links'
MAX_CHILDREN = 100
@@ -31,6 +33,14 @@ module WorkItems
link.work_item_parent.confidential?
end
+
+ def relative_positioning_query_base(parent_link)
+ where(work_item_parent_id: parent_link.work_item_parent_id)
+ end
+
+ def relative_positioning_parent_column
+ :work_item_parent_id
+ end
end
private
diff --git a/app/models/work_items/widgets/hierarchy.rb b/app/models/work_items/widgets/hierarchy.rb
index d0819076efd..ee10c631bcc 100644
--- a/app/models/work_items/widgets/hierarchy.rb
+++ b/app/models/work_items/widgets/hierarchy.rb
@@ -8,7 +8,7 @@ module WorkItems
end
def children
- work_item.work_item_children
+ work_item.work_item_children_by_created_at
end
end
end
diff --git a/app/policies/achievements/achievement_policy.rb b/app/policies/achievements/achievement_policy.rb
new file mode 100644
index 00000000000..9723be0196d
--- /dev/null
+++ b/app/policies/achievements/achievement_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Achievements
+ class AchievementPolicy < ::BasePolicy
+ delegate { @subject.namespace }
+ end
+end
diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb
index 5ef926ef2e3..ca0b51e1385 100644
--- a/app/policies/ci/build_policy.rb
+++ b/app/policies/ci/build_policy.rb
@@ -59,7 +59,7 @@ module Ci
@subject.debug_mode?
end
- condition(:project_read_build, scope: :subject) do
+ condition(:can_read_project_build, scope: :subject) do
can?(:read_build, @subject.project)
end
@@ -71,7 +71,7 @@ module Ci
can?(:developer_access, @subject.project)
end
- rule { project_read_build }.enable :read_build_trace
+ rule { can_read_project_build }.enable :read_build_trace
rule { debug_mode & ~project_update_build }.prevent :read_build_trace
# Authorizing the user to access to protected entities.
@@ -114,7 +114,7 @@ module Ci
prevent :create_build_service_proxy
end
- rule { project_read_build }.enable :read_job_artifacts
+ rule { can_read_project_build }.enable :read_job_artifacts
rule { ~artifacts_public & ~project_developer }.prevent :read_job_artifacts
end
end
diff --git a/app/policies/ci/job_artifact_policy.rb b/app/policies/ci/job_artifact_policy.rb
index e25c7311565..61c935af8ba 100644
--- a/app/policies/ci/job_artifact_policy.rb
+++ b/app/policies/ci/job_artifact_policy.rb
@@ -3,5 +3,20 @@
module Ci
class JobArtifactPolicy < BasePolicy
delegate { @subject.job.project }
+
+ condition(:public_access, scope: :subject) do
+ @subject.public_access?
+ end
+
+ condition(:can_read_project_build, scope: :subject) do
+ can?(:read_build, @subject.job.project)
+ end
+
+ condition(:has_access_to_project) do
+ can?(:developer_access, @subject.job.project)
+ end
+
+ rule { can_read_project_build }.enable :read_job_artifacts
+ rule { ~public_access & ~has_access_to_project }.prevent :read_job_artifacts
end
end
diff --git a/app/policies/description_version_policy.rb b/app/policies/description_version_policy.rb
new file mode 100644
index 00000000000..9ee9df3278b
--- /dev/null
+++ b/app/policies/description_version_policy.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class DescriptionVersionPolicy < BasePolicy
+ delegate { @subject.issuable }
+end
diff --git a/app/policies/email_policy.rb b/app/policies/email_policy.rb
new file mode 100644
index 00000000000..cf10fa893b4
--- /dev/null
+++ b/app/policies/email_policy.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class EmailPolicy < BasePolicy # rubocop:disable Gitlab/NamespacedClass
+ delegate { @subject.user }
+end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 8eea995529c..b2325b7acac 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -126,6 +126,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :read_group_member
enable :read_custom_emoji
enable :read_counts
+ enable :read_achievement
end
rule { ~public_group & ~has_access }.prevent :read_counts
@@ -185,6 +186,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :maintainer_access
enable :read_upload
enable :destroy_upload
+ enable :admin_achievement
end
rule { owner }.policy do
diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb
index aa07bb7dc5f..52796ed1a1d 100644
--- a/app/policies/issuable_policy.rb
+++ b/app/policies/issuable_policy.rb
@@ -16,6 +16,9 @@ class IssuablePolicy < BasePolicy
condition(:is_incident) { @subject.incident? }
+ desc "Issuable is hidden"
+ condition(:hidden, scope: :subject) { @subject.hidden? }
+
rule { can?(:guest_access) & assignee_or_author & ~is_incident }.policy do
enable :read_issue
enable :update_issue
@@ -55,7 +58,7 @@ class IssuablePolicy < BasePolicy
enable :read_issuable_participables
end
- # This rule replicates permissions in NotePolicy#can_read_confidential
+ # This rule replicates permissions in NotePolicy#can_read_internal_note
rule { can?(:reporter_access) | admin }.policy do
enable :read_internal_note
end
diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb
index 491eebe9daf..d1e35793c64 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -21,9 +21,6 @@ class IssuePolicy < IssuablePolicy
desc "Issue is confidential"
condition(:confidential, scope: :subject) { @subject.confidential? }
- desc "Issue is hidden"
- condition(:hidden, scope: :subject) { @subject.hidden? }
-
desc "Issue is persisted"
condition(:persisted, scope: :subject) { @subject.persisted? }
@@ -37,7 +34,7 @@ class IssuePolicy < IssuablePolicy
prevent :read_note
prevent :read_internal_note
prevent :set_note_created_at
- prevent :mark_note_as_confidential
+ prevent :mark_note_as_internal
# these actions on notes are not available on issues/work items yet,
# but preventing any action on work item notes as long as there is no notes widget seems reasonable
prevent :resolve_note
@@ -91,6 +88,10 @@ class IssuePolicy < IssuablePolicy
enable :set_confidentiality
end
+ rule { can?(:guest_access) & can?(:read_issue) }.policy do
+ enable :admin_issue_relation
+ end
+
rule { can_read_crm_contacts }.policy do
enable :read_crm_contacts
end
@@ -100,7 +101,7 @@ class IssuePolicy < IssuablePolicy
end
rule { can?(:reporter_access) }.policy do
- enable :mark_note_as_confidential
+ enable :mark_note_as_internal
end
end
diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb
index 1759cf057e4..49f9225a1d3 100644
--- a/app/policies/merge_request_policy.rb
+++ b/app/policies/merge_request_policy.rb
@@ -29,6 +29,10 @@ class MergeRequestPolicy < IssuablePolicy
enable :update_subscription
end
+ rule { hidden & ~admin }.policy do
+ prevent :read_merge_request
+ end
+
condition(:can_merge) { @subject.can_be_merged_by?(@user) }
rule { can_merge }.policy do
diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb
index 9fd95bbe42d..ccc095f37da 100644
--- a/app/policies/note_policy.rb
+++ b/app/policies/note_policy.rb
@@ -18,7 +18,7 @@ class NotePolicy < BasePolicy
condition(:is_visible) { @subject.system_note_visible_for?(@user) }
- condition(:confidential, scope: :subject) { @subject.confidential? }
+ condition(:internal, scope: :subject) { @subject.confidential? }
# if noteable is a work item it needs to check the notes widget availability
condition(:notes_widget_enabled, scope: :subject) do
@@ -28,7 +28,7 @@ class NotePolicy < BasePolicy
# Should be matched with IssuablePolicy#read_internal_note
# and EpicPolicy#read_internal_note
- condition(:can_read_confidential) do
+ condition(:can_read_internal_note) do
access_level >= Gitlab::Access::REPORTER || admin?
end
@@ -67,11 +67,11 @@ class NotePolicy < BasePolicy
enable :resolve_note
end
- rule { can_read_confidential }.policy do
- enable :mark_note_as_confidential
+ rule { can_read_internal_note }.policy do
+ enable :mark_note_as_internal
end
- rule { confidential & ~can_read_confidential }.policy do
+ rule { internal & ~can_read_internal_note }.policy do
prevent :read_note
prevent :admin_note
prevent :resolve_note
diff --git a/app/policies/project_group_link_policy.rb b/app/policies/project_group_link_policy.rb
new file mode 100644
index 00000000000..00bb246d70b
--- /dev/null
+++ b/app/policies/project_group_link_policy.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class ProjectGroupLinkPolicy < BasePolicy # rubocop:disable Gitlab/NamespacedClass
+ condition(:group_owner_or_project_admin) { group_owner? || project_admin? }
+
+ rule { group_owner_or_project_admin }.enable :admin_project_group_link
+
+ private
+
+ def group_owner?
+ can?(:admin_group, @subject.group)
+ end
+
+ def project_admin?
+ can?(:admin_project, @subject.project)
+ end
+end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index fd3dbb54d57..b85a57f81cd 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -526,6 +526,7 @@ class ProjectPolicy < BasePolicy
enable :read_upload
enable :destroy_upload
enable :admin_incident_management_timeline_event_tag
+ enable :stop_environment
end
rule { public_project & metrics_dashboard_allowed }.policy do
diff --git a/app/policies/projects/branch_rule_policy.rb b/app/policies/projects/branch_rule_policy.rb
new file mode 100644
index 00000000000..9ea15ea26d4
--- /dev/null
+++ b/app/policies/projects/branch_rule_policy.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module Projects
+ class BranchRulePolicy < ::ProtectedBranchPolicy
+ end
+end
+
+Projects::BranchRulePolicy.prepend_mod
diff --git a/app/policies/resource_event_policy.rb b/app/policies/resource_event_policy.rb
new file mode 100644
index 00000000000..d8142212927
--- /dev/null
+++ b/app/policies/resource_event_policy.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ResourceEventPolicy < BasePolicy
+ condition(:can_read_issuable) { can?(:"read_#{@subject.issuable.to_ability_name}", @subject.issuable) }
+end
diff --git a/app/policies/resource_label_event_policy.rb b/app/policies/resource_label_event_policy.rb
index de4748d9890..d9c2eed72e7 100644
--- a/app/policies/resource_label_event_policy.rb
+++ b/app/policies/resource_label_event_policy.rb
@@ -1,8 +1,7 @@
# frozen_string_literal: true
-class ResourceLabelEventPolicy < BasePolicy
+class ResourceLabelEventPolicy < ResourceEventPolicy
condition(:can_read_label) { @subject.label_id.nil? || can?(:read_label, @subject.label) }
- condition(:can_read_issuable) { can?(:"read_#{@subject.issuable.to_ability_name}", @subject.issuable) }
rule { can_read_label }.policy do
enable :read_label
@@ -10,5 +9,6 @@ class ResourceLabelEventPolicy < BasePolicy
rule { can_read_label & can_read_issuable }.policy do
enable :read_resource_label_event
+ enable :read_note
end
end
diff --git a/app/policies/resource_milestone_event_policy.rb b/app/policies/resource_milestone_event_policy.rb
new file mode 100644
index 00000000000..10a1f86fb85
--- /dev/null
+++ b/app/policies/resource_milestone_event_policy.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class ResourceMilestoneEventPolicy < ResourceEventPolicy
+ condition(:can_read_milestone) { @subject.milestone_id.nil? || can?(:read_milestone, @subject.milestone) }
+
+ rule { can_read_milestone }.policy do
+ enable :read_milestone
+ end
+
+ rule { can_read_milestone & can_read_issuable }.policy do
+ enable :read_resource_milestone_event
+ enable :read_note
+ end
+end
diff --git a/app/policies/resource_state_event_policy.rb b/app/policies/resource_state_event_policy.rb
new file mode 100644
index 00000000000..34df2e96eb8
--- /dev/null
+++ b/app/policies/resource_state_event_policy.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class ResourceStateEventPolicy < ResourceEventPolicy
+ condition(:can_read_issuable) { can?(:"read_#{@subject.issuable.to_ability_name}", @subject.issuable) }
+
+ rule { can_read_issuable }.policy do
+ enable :read_resource_state_event
+ enable :read_note
+ end
+end
diff --git a/app/policies/todo_policy.rb b/app/policies/todo_policy.rb
index d63eb9407f8..3b4be29664f 100644
--- a/app/policies/todo_policy.rb
+++ b/app/policies/todo_policy.rb
@@ -11,18 +11,18 @@ class TodoPolicy < BasePolicy
@user && @subject.target&.readable_by?(@user)
end
- desc "Todo has confidential note"
- condition(:has_confidential_note, scope: :subject) { @subject&.note&.confidential? }
+ desc "Todo has internal note"
+ condition(:has_internal_note, scope: :subject) { @subject&.note&.confidential? }
- desc "User can read the todo's confidential note"
- condition(:can_read_todo_confidential_note) do
+ desc "User can read the todo's internal note"
+ condition(:can_read_todo_internal_note) do
@user && @user.can?(:read_internal_note, @subject.target)
end
rule { own_todo & can_read_target }.enable :read_todo
rule { can?(:read_todo) }.enable :update_todo
- rule { has_confidential_note & ~can_read_todo_confidential_note }.policy do
+ rule { has_internal_note & ~can_read_todo_internal_note }.policy do
prevent :read_todo
prevent :update_todo
end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index 4f3dafbf5c8..ed5b01e52b4 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -30,6 +30,7 @@ class UserPolicy < BasePolicy
enable :read_group_count
enable :read_user_groups
enable :read_saved_replies
+ enable :read_user_email_address
end
rule { default }.enable :read_user_profile
diff --git a/app/policies/users/namespace_commit_email_policy.rb b/app/policies/users/namespace_commit_email_policy.rb
new file mode 100644
index 00000000000..849ebd04688
--- /dev/null
+++ b/app/policies/users/namespace_commit_email_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Users
+ class NamespaceCommitEmailPolicy < BasePolicy
+ delegate { @subject.user }
+ end
+end
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index 7242a80b924..9a586a1733f 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -33,13 +33,10 @@ module Ci
end
def runner_variables
- stop_expanding_raw_refs = ::Feature.enabled?(:ci_raw_variables_in_yaml_config, project)
-
variables
.sort_and_expand_all(keep_undefined: true,
expand_file_refs: false,
- expand_raw_refs: !stop_expanding_raw_refs,
- project: project)
+ expand_raw_refs: false)
.to_runner_variables
end
diff --git a/app/presenters/packages/nuget/packages_metadata_presenter.rb b/app/presenters/packages/nuget/packages_metadata_presenter.rb
index 5f5859d46c9..9f1dee17cea 100644
--- a/app/presenters/packages/nuget/packages_metadata_presenter.rb
+++ b/app/presenters/packages/nuget/packages_metadata_presenter.rb
@@ -10,6 +10,10 @@ module Packages
def initialize(packages)
@packages = packages
+ .preload_nuget_files
+ .preload_nuget_metadatum
+ .including_tags
+ .including_dependency_links_with_nuget_metadatum
end
def count
@@ -23,12 +27,14 @@ module Packages
private
def summary
+ packages_with_metadata = @packages.map { |pkg| metadata_for(pkg) }
+
{
json_url: json_url,
lower_version: lower_version,
upper_version: upper_version,
- packages_count: @packages.count,
- packages: @packages.map { |pkg| metadata_for(pkg) }
+ packages_count: packages_with_metadata.size,
+ packages: packages_with_metadata
}
end
diff --git a/app/presenters/packages/nuget/presenter_helpers.rb b/app/presenters/packages/nuget/presenter_helpers.rb
index 5334e4aa6f8..82ed80d8372 100644
--- a/app/presenters/packages/nuget/presenter_helpers.rb
+++ b/app/presenters/packages/nuget/presenter_helpers.rb
@@ -8,7 +8,6 @@ module Packages
BLANK_STRING = ''
PACKAGE_DEPENDENCY_GROUP = 'PackageDependencyGroup'
PACKAGE_DEPENDENCY = 'PackageDependency'
- NUGET_PACKAGE_FORMAT = 'nupkg'
private
@@ -27,8 +26,7 @@ module Packages
end
def archive_url_for(package)
- package_filename = package.installable_package_files
- .with_format(NUGET_PACKAGE_FORMAT)
+ package_filename = package.installable_nuget_package_files
.last
&.file_name
path = api_v4_projects_packages_nuget_download_package_name_package_version_package_filename_path(
@@ -75,8 +73,6 @@ module Packages
def dependency_links_grouped_by_target_framework(package)
package
.dependency_links
- .includes_dependency
- .preload_nuget_metadatum
.group_by { |dependency_link| dependency_link.nuget_metadatum&.target_framework }
end
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 4d1a9b3f589..780740fd805 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -5,6 +5,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
include GitlabRoutingHelper
include StorageHelper
include TreeHelper
+ include Ci::PipelineEditorHelper
include IconsHelper
include BlobHelper
include ChecksCollaboration
@@ -349,6 +350,8 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
def gitlab_ci_anchor_data
+ return unless can_view_pipeline_editor?(project)
+
if cicd_missing?
AnchorData.new(false,
statistic_icon + _('Set up CI/CD'),
diff --git a/app/serializers/ci/downloadable_artifact_entity.rb b/app/serializers/ci/downloadable_artifact_entity.rb
index 1f3885f0715..2e8aafcee43 100644
--- a/app/serializers/ci/downloadable_artifact_entity.rb
+++ b/app/serializers/ci/downloadable_artifact_entity.rb
@@ -8,7 +8,7 @@ module Ci
artifacts = pipeline.downloadable_artifacts
if Feature.enabled?(:non_public_artifacts)
- artifacts = artifacts.select { |artifact| can?(request.current_user, :read_job_artifacts, artifact.job) }
+ artifacts = artifacts.select { |artifact| can?(request.current_user, :read_job_artifacts, artifact) }
end
BuildArtifactEntity.represent(artifacts, options.merge(project: pipeline.project))
diff --git a/app/serializers/ci/pipeline_entity.rb b/app/serializers/ci/pipeline_entity.rb
index 143017c5159..5e6ae0986df 100644
--- a/app/serializers/ci/pipeline_entity.rb
+++ b/app/serializers/ci/pipeline_entity.rb
@@ -10,7 +10,7 @@ class Ci::PipelineEntity < Grape::Entity
expose :iid
expose :user, using: UserEntity
expose :active?, as: :active
- expose :name, if: -> (pipeline, _) { Feature.enabled?(:pipeline_name, pipeline.project) }
+ expose :name
# 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
diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb
index 759d1e0f10a..5b30c0bb58c 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: true) if options[:merge_conflicts_in_diff])
+ conflicts: conflicts(allow_tree_conflicts: true)
)
)
end
diff --git a/app/serializers/diffs_metadata_entity.rb b/app/serializers/diffs_metadata_entity.rb
index ace5105dda5..e55f31a8376 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: true) if options[:merge_conflicts_in_diff])
+ conflicts: conflicts(allow_tree_conflicts: true)
)
)
end
diff --git a/app/serializers/group_link/group_group_link_entity.rb b/app/serializers/group_link/group_group_link_entity.rb
index 563a75ccdaa..d5d7eea74ea 100644
--- a/app/serializers/group_link/group_group_link_entity.rb
+++ b/app/serializers/group_link/group_group_link_entity.rb
@@ -10,6 +10,10 @@ module GroupLink
private
+ def can_admin_group_link?(group_link, options)
+ can?(current_user, admin_permission_name, group_link.shared_from)
+ end
+
def admin_permission_name
:admin_group_member
end
diff --git a/app/serializers/group_link/group_link_entity.rb b/app/serializers/group_link/group_link_entity.rb
index 73c9931fc70..4cc7e9f3c8c 100644
--- a/app/serializers/group_link/group_link_entity.rb
+++ b/app/serializers/group_link/group_link_entity.rb
@@ -36,7 +36,7 @@ module GroupLink
end
expose :can_remove do |group_link, options|
- can_admin_shared_from?(group_link, options)
+ direct_member?(group_link, options) && can_admin_group_link?(group_link, options)
end
expose :is_direct_member do |group_link, options|
diff --git a/app/serializers/group_link/project_group_link_entity.rb b/app/serializers/group_link/project_group_link_entity.rb
index c667bcd5bf4..d246bff1c58 100644
--- a/app/serializers/group_link/project_group_link_entity.rb
+++ b/app/serializers/group_link/project_group_link_entity.rb
@@ -10,6 +10,10 @@ module GroupLink
private
+ def can_admin_group_link?(group_link, options)
+ can?(current_user, :admin_project_group_link, group_link)
+ end
+
def admin_permission_name
:admin_project_member
end
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index 397f333008c..a38f345f617 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -48,7 +48,7 @@ class IssueEntity < IssuableEntity
end
expose :can_create_confidential_note do |issue|
- can?(request.current_user, :mark_note_as_confidential, issue)
+ can?(request.current_user, :mark_note_as_internal, issue)
end
expose :can_update do |issue|
diff --git a/app/serializers/merge_requests/pipeline_entity.rb b/app/serializers/merge_requests/pipeline_entity.rb
index 76e75a8ca6d..cf050b32d21 100644
--- a/app/serializers/merge_requests/pipeline_entity.rb
+++ b/app/serializers/merge_requests/pipeline_entity.rb
@@ -5,7 +5,7 @@ class MergeRequests::PipelineEntity < Grape::Entity
expose :id
expose :active?, as: :active
- expose :name, if: -> (pipeline, _) { Feature.enabled?(:pipeline_name, pipeline.project) }
+ expose :name
expose :path do |pipeline|
project_pipeline_path(pipeline.project, pipeline)
@@ -30,7 +30,7 @@ class MergeRequests::PipelineEntity < Grape::Entity
rel = pipeline.downloadable_artifacts
if Feature.enabled?(:non_public_artifacts, type: :development)
- rel = rel.select { |artifact| can?(request.current_user, :read_job_artifacts, artifact.job) }
+ rel = rel.select { |artifact| can?(request.current_user, :read_job_artifacts, artifact) }
end
BuildArtifactEntity.represent(rel, options.merge(project: pipeline.project))
diff --git a/app/serializers/paginated_diff_entity.rb b/app/serializers/paginated_diff_entity.rb
index b79a0937659..67f4014990c 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: true) if options[:merge_conflicts_in_diff])
+ conflicts: conflicts(allow_tree_conflicts: true)
)
)
end
diff --git a/app/serializers/remote_mirror_entity.rb b/app/serializers/remote_mirror_entity.rb
index 440e4274668..7eddb3fef4a 100644
--- a/app/serializers/remote_mirror_entity.rb
+++ b/app/serializers/remote_mirror_entity.rb
@@ -13,3 +13,5 @@ class RemoteMirrorEntity < Grape::Entity
remote_mirror.ssh_known_hosts_fingerprints.as_json
end
end
+
+RemoteMirrorEntity.prepend_mod
diff --git a/app/services/achievements/base_service.rb b/app/services/achievements/base_service.rb
new file mode 100644
index 00000000000..0a8e6ee3c78
--- /dev/null
+++ b/app/services/achievements/base_service.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Achievements
+ class BaseService < ::BaseContainerService
+ def initialize(namespace:, current_user: nil, params: {})
+ @namespace = namespace
+ super(container: namespace, current_user: current_user, params: params)
+ end
+
+ private
+
+ def allowed?
+ current_user&.can?(:admin_achievement, @namespace)
+ end
+
+ def error(message)
+ ServiceResponse.error(message: Array(message))
+ end
+ end
+end
diff --git a/app/services/achievements/create_service.rb b/app/services/achievements/create_service.rb
new file mode 100644
index 00000000000..2843df6c191
--- /dev/null
+++ b/app/services/achievements/create_service.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Achievements
+ class CreateService < BaseService
+ def execute
+ return error_no_permissions unless allowed?
+
+ achievement = Achievements::Achievement.create(params.merge(namespace_id: @namespace.id))
+
+ return error_creating(achievement) unless achievement.persisted?
+
+ ServiceResponse.success(payload: achievement)
+ end
+
+ private
+
+ def error_no_permissions
+ error('You have insufficient permissions to create achievements for this namespace')
+ end
+
+ def error_creating(achievement)
+ error(achievement&.errors&.full_messages || 'Failed to create achievement')
+ end
+ end
+end
diff --git a/app/services/boards/base_items_list_service.rb b/app/services/boards/base_items_list_service.rb
index 2a9cbb83cc4..bf68aee2c1f 100644
--- a/app/services/boards/base_items_list_service.rb
+++ b/app/services/boards/base_items_list_service.rb
@@ -18,7 +18,6 @@ module Boards
# TODO: eliminate need for SQL literal fragment
columns = Arel.sql(fields.values_at(*keys).join(', '))
results = item_model.where(id: collection_ids)
- results = query_additions(results, required_fields)
results = results.select(columns)
Hash[keys.zip(results.pluck(columns).flatten)]
@@ -27,11 +26,6 @@ module Boards
private
- # override if needed
- def query_additions(items, required_fields)
- items
- end
-
def collection_ids
@collection_ids ||= init_collection.select(item_model.arel_table[:id])
end
diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb
index 124b5964232..35a35e7b7c9 100644
--- a/app/services/bulk_imports/create_service.rb
+++ b/app/services/bulk_imports/create_service.rb
@@ -36,6 +36,8 @@ module BulkImports
end
def execute
+ validate!
+
bulk_import = create_bulk_import
Gitlab::Tracking.event(self.class.name, 'create', label: 'bulk_import_group')
@@ -43,7 +45,8 @@ module BulkImports
BulkImportWorker.perform_async(bulk_import.id)
ServiceResponse.success(payload: bulk_import)
- rescue ActiveRecord::RecordInvalid => e
+
+ rescue ActiveRecord::RecordInvalid, BulkImports::Error, BulkImports::NetworkError => e
ServiceResponse.error(
message: e.message,
http_status: :unprocessable_entity
@@ -52,6 +55,11 @@ module BulkImports
private
+ def validate!
+ client.validate_instance_version!
+ client.validate_import_scopes!
+ end
+
def create_bulk_import
BulkImport.transaction do
bulk_import = BulkImport.create!(
@@ -70,7 +78,8 @@ module BulkImports
source_type: entity[:source_type],
source_full_path: entity[:source_full_path],
destination_slug: entity[:destination_slug],
- destination_namespace: entity[:destination_namespace]
+ destination_namespace: entity[:destination_namespace],
+ migrate_projects: Gitlab::Utils.to_boolean(entity[:migrate_projects], default: true)
)
end
diff --git a/app/services/captcha/captcha_verification_service.rb b/app/services/captcha/captcha_verification_service.rb
index 3ed8ea12f3a..b7b699f7108 100644
--- a/app/services/captcha/captcha_verification_service.rb
+++ b/app/services/captcha/captcha_verification_service.rb
@@ -5,7 +5,7 @@ module Captcha
# Encapsulates logic of checking captchas.
#
class CaptchaVerificationService
- include Recaptcha::Verify
+ include Recaptcha::Adapters::ControllerMethods
# Currently the only value that is used out of the request by the reCAPTCHA library
# is 'remote_ip'. Therefore, we just create a struct to avoid passing the full request
@@ -45,7 +45,7 @@ module Captcha
attr_reader :spam_params
- # The recaptcha library's Recaptcha::Verify#verify_recaptcha method requires that
+ # The recaptcha library's Recaptcha::Adapters::ControllerMethods#verify_recaptcha method requires that
# 'request' be a readable attribute - it doesn't support passing it as an options argument.
attr_reader :request
end
diff --git a/app/services/chat_names/authorize_user_service.rb b/app/services/chat_names/authorize_user_service.rb
index 6c28a1cea7e..32714831fb8 100644
--- a/app/services/chat_names/authorize_user_service.rb
+++ b/app/services/chat_names/authorize_user_service.rb
@@ -4,8 +4,7 @@ module ChatNames
class AuthorizeUserService
include Gitlab::Routing
- def initialize(integration, params)
- @integration = integration
+ def initialize(params)
@params = params
end
@@ -29,7 +28,6 @@ module ChatNames
def chat_name_params
{
- integration_id: @integration.id,
team_id: @params[:team_id],
team_domain: @params[:team_domain],
chat_id: @params[:user_id],
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 9c3cc803587..eb25aeaf5a5 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -157,6 +157,13 @@ module Ci
duration >= LOG_MAX_CREATION_THRESHOLD
end
+
+ l.log_when do |observations|
+ pipeline_includes_count = observations['pipeline_includes_count']
+ next false unless pipeline_includes_count
+
+ pipeline_includes_count.to_i > Gitlab::Ci::Config::External::Context::MAX_INCLUDES
+ end
end
end
end
diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb
index ee9982cf3ab..6e2ba76682f 100644
--- a/app/services/ci/job_artifacts/create_service.rb
+++ b/app/services/ci/job_artifacts/create_service.rb
@@ -92,7 +92,8 @@ module Ci
file: artifacts_file,
file_type: params[:artifact_type],
file_format: params[:artifact_format],
- file_sha256: artifacts_file.sha256
+ file_sha256: artifacts_file.sha256,
+ accessibility: accessibility(params)
)
)
@@ -102,7 +103,8 @@ module Ci
file: metadata_file,
file_type: :metadata,
file_format: :gzip,
- file_sha256: metadata_file.sha256
+ file_sha256: metadata_file.sha256,
+ accessibility: accessibility(params)
)
)
end
@@ -110,6 +112,10 @@ module Ci
[artifact, artifact_metadata]
end
+ def accessibility(params)
+ params[:accessibility] || 'public'
+ end
+
def parse_artifact(artifact)
case artifact.file_type
when 'dotenv' then parse_dotenv_artifact(artifact)
diff --git a/app/services/ci/job_artifacts/delete_service.rb b/app/services/ci/job_artifacts/delete_service.rb
index c9d590eccc4..fc5c6b12389 100644
--- a/app/services/ci/job_artifacts/delete_service.rb
+++ b/app/services/ci/job_artifacts/delete_service.rb
@@ -26,8 +26,7 @@ module Ci
if result.fetch(:status) == :success
ServiceResponse.success(payload:
{
- destroyed_artifacts_count: result.fetch(:destroyed_artifacts_count),
- statistics_updates: result.fetch(:statistics_updates)
+ destroyed_artifacts_count: result.fetch(:destroyed_artifacts_count)
})
else
ServiceResponse.error(message: result.fetch(:message))
diff --git a/app/services/ci/job_artifacts/destroy_associations_service.rb b/app/services/ci/job_artifacts/destroy_associations_service.rb
index 794d24eadf2..fd3e69a5913 100644
--- a/app/services/ci/job_artifacts/destroy_associations_service.rb
+++ b/app/services/ci/job_artifacts/destroy_associations_service.rb
@@ -2,27 +2,32 @@
module Ci
module JobArtifacts
+ # This class is used by Ci::JobArtifact's FastDestroyAll implementation.
+ # Ci::JobArtifact.begin_fast_destroy instantiates this service and calls #destroy_records.
+ # This will set @statistics_updates instance variables.
+ # The same instance is passed to Ci::JobArtifact.finalize_fast_destroy, which then calls
+ # #update_statistics, using @statistics_updates set by #destroy_records.
class DestroyAssociationsService
BATCH_SIZE = 100
def initialize(job_artifacts_relation)
@job_artifacts_relation = job_artifacts_relation
- @statistics = {}
+ @statistics_updates = {}
end
def destroy_records
@job_artifacts_relation.each_batch(of: BATCH_SIZE) do |relation|
service = Ci::JobArtifacts::DestroyBatchService.new(relation, pick_up_at: Time.current)
result = service.execute(update_stats: false)
- updates = result[:statistics_updates]
-
- @statistics.merge!(updates) { |_key, oldval, newval| newval + oldval }
+ @statistics_updates.merge!(result[:statistics_updates]) do |_project, existing_updates, new_updates|
+ existing_updates.concat(new_updates)
+ end
end
end
def update_statistics
- @statistics.each do |project, delta|
- project.increment_statistic_value(Ci::JobArtifact.project_statistics_name, delta)
+ @statistics_updates.each do |project, increments|
+ ProjectStatistics.bulk_increment_statistic(project, Ci::JobArtifact.project_statistics_name, increments)
end
end
end
diff --git a/app/services/ci/job_artifacts/destroy_batch_service.rb b/app/services/ci/job_artifacts/destroy_batch_service.rb
index e0307d9bd53..7cb1be95a3e 100644
--- a/app/services/ci/job_artifacts/destroy_batch_service.rb
+++ b/app/services/ci/job_artifacts/destroy_batch_service.rb
@@ -46,14 +46,13 @@ module Ci
after_batch_destroy_hook(@job_artifacts)
- # This is executed outside of the transaction because it depends on Redis
update_project_statistics! if update_stats
+
increment_monitoring_statistics(artifacts_count, artifacts_bytes)
Gitlab::Ci::Artifacts::Logger.log_deleted(@job_artifacts, 'Ci::JobArtifacts::DestroyBatchService#execute')
- success(destroyed_artifacts_count: artifacts_count,
- statistics_updates: affected_project_statistics)
+ success(destroyed_artifacts_count: artifacts_count, statistics_updates: statistics_updates_per_project)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -74,17 +73,18 @@ module Ci
# using ! here since this can't be called inside a transaction
def update_project_statistics!
- affected_project_statistics.each do |project, delta|
- project.increment_statistic_value(Ci::JobArtifact.project_statistics_name, delta)
+ statistics_updates_per_project.each do |project, increments|
+ ProjectStatistics.bulk_increment_statistic(project, Ci::JobArtifact.project_statistics_name, increments)
end
end
- def affected_project_statistics
- strong_memoize(:affected_project_statistics) do
- artifacts_by_project = @job_artifacts.group_by(&:project)
- artifacts_by_project.each.with_object({}) do |(project, artifacts), accumulator|
- delta = -artifacts.sum { |artifact| artifact.size.to_i }
- accumulator[project] = delta
+ def statistics_updates_per_project
+ strong_memoize(:statistics_updates_per_project) do
+ result = Hash.new { |updates, project| updates[project] = [] }
+
+ @job_artifacts.each_with_object(result) do |job_artifact, result|
+ increment = Gitlab::Counters::Increment.new(amount: -job_artifact.size.to_i, ref: job_artifact.id)
+ result[job_artifact.project] << increment
end
end
end
diff --git a/app/services/clusters/aws/authorize_role_service.rb b/app/services/clusters/aws/authorize_role_service.rb
deleted file mode 100644
index 7ca20289bf7..00000000000
--- a/app/services/clusters/aws/authorize_role_service.rb
+++ /dev/null
@@ -1,74 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Aws
- class AuthorizeRoleService
- attr_reader :user
-
- Response = Struct.new(:status, :body)
-
- ERRORS = [
- ActiveRecord::RecordInvalid,
- ActiveRecord::RecordNotFound,
- Clusters::Aws::FetchCredentialsService::MissingRoleError,
- ::Aws::Errors::MissingCredentialsError,
- ::Aws::STS::Errors::ServiceError
- ].freeze
-
- def initialize(user, params:)
- @user = user
- @role_arn = params[:role_arn]
- @region = params[:region]
- end
-
- def execute
- ensure_role_exists!
- update_role_arn!
-
- Response.new(:ok, credentials)
- rescue *ERRORS => e
- Gitlab::ErrorTracking.track_exception(e)
-
- Response.new(:unprocessable_entity, response_details(e))
- end
-
- private
-
- attr_reader :role, :role_arn, :region
-
- def ensure_role_exists!
- @role = ::Aws::Role.find_by_user_id!(user.id)
- end
-
- def update_role_arn!
- role.update!(role_arn: role_arn, region: region)
- end
-
- def credentials
- Clusters::Aws::FetchCredentialsService.new(role).execute
- end
-
- def response_details(exception)
- message =
- case exception
- when ::Aws::STS::Errors::AccessDenied
- _("Access denied: %{error}") % { error: exception.message }
- when ::Aws::STS::Errors::ServiceError
- _("AWS service error: %{error}") % { error: exception.message }
- when ActiveRecord::RecordNotFound
- _("Error: Unable to find AWS role for current user")
- when ActiveRecord::RecordInvalid
- exception.message
- when Clusters::Aws::FetchCredentialsService::MissingRoleError
- _("Error: No AWS provision role found for user")
- when ::Aws::Errors::MissingCredentialsError
- _("Error: No AWS credentials were supplied")
- else
- _('An error occurred while authorizing your role')
- end
-
- { message: message }.compact
- end
- end
- end
-end
diff --git a/app/services/clusters/aws/fetch_credentials_service.rb b/app/services/clusters/aws/fetch_credentials_service.rb
deleted file mode 100644
index e38852c7ec7..00000000000
--- a/app/services/clusters/aws/fetch_credentials_service.rb
+++ /dev/null
@@ -1,80 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Aws
- class FetchCredentialsService
- attr_reader :provision_role
-
- MissingRoleError = Class.new(StandardError)
-
- def initialize(provision_role, provider: nil)
- @provision_role = provision_role
- @provider = provider
- @region = provider&.region || provision_role&.region || Clusters::Providers::Aws::DEFAULT_REGION
- end
-
- def execute
- raise MissingRoleError, 'AWS provisioning role not configured' unless provision_role.present?
-
- ::Aws::AssumeRoleCredentials.new(
- client: client,
- role_arn: provision_role.role_arn,
- role_session_name: session_name,
- external_id: provision_role.role_external_id,
- policy: session_policy
- ).credentials
- end
-
- private
-
- attr_reader :provider, :region
-
- def client
- ::Aws::STS::Client.new(**client_args)
- end
-
- def client_args
- { region: region, credentials: gitlab_credentials }.compact
- end
-
- def gitlab_credentials
- # These are not needed for IAM instance profiles
- return unless access_key_id.present? && secret_access_key.present?
-
- ::Aws::Credentials.new(access_key_id, secret_access_key)
- end
-
- def access_key_id
- Gitlab::CurrentSettings.eks_access_key_id
- end
-
- def secret_access_key
- Gitlab::CurrentSettings.eks_secret_access_key
- end
-
- ##
- # If we haven't created a provider record yet,
- # we restrict ourselves to read-only access so
- # that we can safely expose credentials to the
- # frontend (to be used when populating the
- # creation form).
- def session_policy
- if provider.nil?
- File.read(read_only_policy)
- end
- end
-
- def read_only_policy
- Rails.root.join('vendor', 'aws', 'iam', "eks_cluster_read_only_policy.json")
- end
-
- def session_name
- if provider.present?
- "gitlab-eks-cluster-#{provider.cluster_id}-user-#{provision_role.user_id}"
- else
- "gitlab-eks-autofill-user-#{provision_role.user_id}"
- end
- end
- end
- end
-end
diff --git a/app/services/clusters/aws/finalize_creation_service.rb b/app/services/clusters/aws/finalize_creation_service.rb
deleted file mode 100644
index 54f07e1d44c..00000000000
--- a/app/services/clusters/aws/finalize_creation_service.rb
+++ /dev/null
@@ -1,139 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Aws
- class FinalizeCreationService
- include Gitlab::Utils::StrongMemoize
-
- attr_reader :provider
-
- delegate :cluster, to: :provider
-
- def execute(provider)
- @provider = provider
-
- configure_provider
- create_gitlab_service_account!
- configure_platform_kubernetes
- configure_node_authentication!
-
- cluster.save!
- rescue ::Aws::CloudFormation::Errors::ServiceError => e
- log_service_error(e.class.name, provider.id, e.message)
- provider.make_errored!(s_('ClusterIntegration|Failed to fetch CloudFormation stack: %{message}') % { message: e.message })
- rescue Kubeclient::HttpError => e
- log_service_error(e.class.name, provider.id, e.message)
- provider.make_errored!(s_('ClusterIntegration|Failed to run Kubeclient: %{message}') % { message: e.message })
- rescue ActiveRecord::RecordInvalid => e
- log_service_error(e.class.name, provider.id, e.message)
- provider.make_errored!(s_('ClusterIntegration|Failed to configure EKS provider: %{message}') % { message: e.message })
- end
-
- private
-
- def create_gitlab_service_account!
- Clusters::Kubernetes::CreateOrUpdateServiceAccountService.gitlab_creator(
- kube_client,
- rbac: true
- ).execute
- end
-
- def configure_provider
- provider.status_event = :make_created
- end
-
- def configure_platform_kubernetes
- cluster.build_platform_kubernetes(
- api_url: cluster_endpoint,
- ca_cert: cluster_certificate,
- token: request_kubernetes_token)
- end
-
- def request_kubernetes_token
- Clusters::Kubernetes::FetchKubernetesTokenService.new(
- kube_client,
- Clusters::Kubernetes::GITLAB_ADMIN_TOKEN_NAME,
- Clusters::Kubernetes::GITLAB_SERVICE_ACCOUNT_NAMESPACE
- ).execute
- end
-
- def kube_client
- @kube_client ||= build_kube_client!(
- cluster_endpoint,
- cluster_certificate
- )
- end
-
- def build_kube_client!(api_url, ca_pem)
- raise "Incomplete settings" unless api_url
-
- Gitlab::Kubernetes::KubeClient.new(
- api_url,
- auth_options: kubeclient_auth_options,
- ssl_options: kubeclient_ssl_options(ca_pem),
- http_proxy_uri: ENV['http_proxy']
- )
- end
-
- def kubeclient_auth_options
- { bearer_token: Kubeclient::AmazonEksCredentials.token(provider.credentials, cluster.name) }
- end
-
- def kubeclient_ssl_options(ca_pem)
- opts = { verify_ssl: OpenSSL::SSL::VERIFY_PEER }
-
- if ca_pem.present?
- opts[:cert_store] = OpenSSL::X509::Store.new
- opts[:cert_store].add_cert(OpenSSL::X509::Certificate.new(ca_pem))
- end
-
- opts
- end
-
- def cluster_stack
- @cluster_stack ||= provider.api_client.describe_stacks(stack_name: provider.cluster.name).stacks.first
- end
-
- def stack_output_value(key)
- cluster_stack.outputs.detect { |output| output.output_key == key }.output_value
- end
-
- def node_instance_role_arn
- stack_output_value('NodeInstanceRole')
- end
-
- def cluster_endpoint
- strong_memoize(:cluster_endpoint) do
- stack_output_value('ClusterEndpoint')
- end
- end
-
- def cluster_certificate
- strong_memoize(:cluster_certificate) do
- Base64.decode64(stack_output_value('ClusterCertificate'))
- end
- end
-
- def configure_node_authentication!
- kube_client.create_config_map(node_authentication_config)
- end
-
- def node_authentication_config
- Gitlab::Kubernetes::ConfigMaps::AwsNodeAuth.new(node_instance_role_arn).generate
- end
-
- def logger
- @logger ||= Gitlab::Kubernetes::Logger.build
- end
-
- def log_service_error(exception, provider_id, message)
- logger.error(
- exception: exception.class.name,
- service: self.class.name,
- provider_id: provider_id,
- message: message
- )
- end
- end
- end
-end
diff --git a/app/services/clusters/aws/provision_service.rb b/app/services/clusters/aws/provision_service.rb
deleted file mode 100644
index b454a7a5f59..00000000000
--- a/app/services/clusters/aws/provision_service.rb
+++ /dev/null
@@ -1,85 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Aws
- class ProvisionService
- attr_reader :provider
-
- def execute(provider)
- @provider = provider
-
- configure_provider_credentials
- provision_cluster
-
- if provider.make_creating
- WaitForClusterCreationWorker.perform_in(
- Clusters::Aws::VerifyProvisionStatusService::INITIAL_INTERVAL,
- provider.cluster_id
- )
- else
- provider.make_errored!("Failed to update provider record; #{provider.errors.full_messages}")
- end
- rescue Clusters::Aws::FetchCredentialsService::MissingRoleError
- provider.make_errored!('Amazon role is not configured')
- rescue ::Aws::Errors::MissingCredentialsError
- provider.make_errored!('Amazon credentials are not configured')
- rescue ::Aws::STS::Errors::ServiceError => e
- provider.make_errored!("Amazon authentication failed; #{e.message}")
- rescue ::Aws::CloudFormation::Errors::ServiceError => e
- provider.make_errored!("Amazon CloudFormation request failed; #{e.message}")
- end
-
- private
-
- def provision_role
- provider.created_by_user&.aws_role
- end
-
- def credentials
- @credentials ||= Clusters::Aws::FetchCredentialsService.new(
- provision_role,
- provider: provider
- ).execute
- end
-
- def configure_provider_credentials
- provider.update!(
- access_key_id: credentials.access_key_id,
- secret_access_key: credentials.secret_access_key,
- session_token: credentials.session_token
- )
- end
-
- def provision_cluster
- provider.api_client.create_stack(
- stack_name: provider.cluster.name,
- template_body: stack_template,
- parameters: parameters,
- capabilities: ["CAPABILITY_IAM"]
- )
- end
-
- def parameters
- [
- parameter('ClusterName', provider.cluster.name),
- parameter('ClusterRole', provider.role_arn),
- parameter('KubernetesVersion', provider.kubernetes_version),
- parameter('ClusterControlPlaneSecurityGroup', provider.security_group_id),
- parameter('VpcId', provider.vpc_id),
- parameter('Subnets', provider.subnet_ids.join(',')),
- parameter('NodeAutoScalingGroupDesiredCapacity', provider.num_nodes.to_s),
- parameter('NodeInstanceType', provider.instance_type),
- parameter('KeyName', provider.key_name)
- ]
- end
-
- def parameter(key, value)
- { parameter_key: key, parameter_value: value }
- end
-
- def stack_template
- File.read(Rails.root.join('vendor', 'aws', 'cloudformation', 'eks_cluster.yaml'))
- end
- end
- end
-end
diff --git a/app/services/clusters/aws/verify_provision_status_service.rb b/app/services/clusters/aws/verify_provision_status_service.rb
deleted file mode 100644
index 99532662bc4..00000000000
--- a/app/services/clusters/aws/verify_provision_status_service.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Aws
- class VerifyProvisionStatusService
- attr_reader :provider
-
- INITIAL_INTERVAL = 5.minutes
- POLL_INTERVAL = 1.minute
- TIMEOUT = 30.minutes
-
- def execute(provider)
- @provider = provider
-
- case cluster_stack.stack_status
- when 'CREATE_IN_PROGRESS'
- continue_creation
- when 'CREATE_COMPLETE'
- finalize_creation
- else
- provider.make_errored!("Unexpected status; #{cluster_stack.stack_status}")
- end
- rescue ::Aws::CloudFormation::Errors::ServiceError => e
- provider.make_errored!("Amazon CloudFormation request failed; #{e.message}")
- end
-
- private
-
- def cluster_stack
- @cluster_stack ||= provider.api_client.describe_stacks(stack_name: provider.cluster.name).stacks.first
- end
-
- def continue_creation
- if timeout_threshold.future?
- WaitForClusterCreationWorker.perform_in(POLL_INTERVAL, provider.cluster_id)
- else
- provider.make_errored!(_('Kubernetes cluster creation time exceeds timeout; %{timeout}') % { timeout: TIMEOUT })
- end
- end
-
- def timeout_threshold
- cluster_stack.creation_time + TIMEOUT
- end
-
- def finalize_creation
- Clusters::Aws::FinalizeCreationService.new.execute(provider)
- end
- end
- end
-end
diff --git a/app/services/clusters/create_service.rb b/app/services/clusters/create_service.rb
index cb2de8b943c..4c7384806ad 100644
--- a/app/services/clusters/create_service.rb
+++ b/app/services/clusters/create_service.rb
@@ -24,9 +24,7 @@ module Clusters
return cluster if cluster.errors.present?
- cluster.tap do |cluster|
- cluster.save && ClusterProvisionWorker.perform_async(cluster.id)
- end
+ cluster.tap(&:save)
end
private
diff --git a/app/services/clusters/gcp/fetch_operation_service.rb b/app/services/clusters/gcp/fetch_operation_service.rb
deleted file mode 100644
index 6c648b443a0..00000000000
--- a/app/services/clusters/gcp/fetch_operation_service.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Gcp
- class FetchOperationService
- def execute(provider)
- operation = provider.api_client.projects_zones_operations(
- provider.gcp_project_id,
- provider.zone,
- provider.operation_id)
-
- yield(operation) if block_given?
- rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e
- logger.error(
- exception: e.class.name,
- service: self.class.name,
- provider_id: provider.id,
- message: e.message
- )
-
- provider.make_errored!("Failed to request to CloudPlatform; #{e.message}")
- end
-
- private
-
- def logger
- @logger ||= Gitlab::Kubernetes::Logger.build
- end
- end
- end
-end
diff --git a/app/services/clusters/gcp/finalize_creation_service.rb b/app/services/clusters/gcp/finalize_creation_service.rb
deleted file mode 100644
index 73d6fc4dc8f..00000000000
--- a/app/services/clusters/gcp/finalize_creation_service.rb
+++ /dev/null
@@ -1,127 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Gcp
- class FinalizeCreationService
- attr_reader :provider
-
- def execute(provider)
- @provider = provider
-
- configure_provider
- create_gitlab_service_account!
- configure_kubernetes
- configure_pre_installed_knative if provider.knative_pre_installed?
- cluster.save!
- rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e
- log_service_error(e.class.name, provider.id, e.message)
- provider.make_errored!(s_('ClusterIntegration|Failed to request to Google Cloud Platform: %{message}') % { message: e.message })
- rescue Kubeclient::HttpError => e
- log_service_error(e.class.name, provider.id, e.message)
- provider.make_errored!(s_('ClusterIntegration|Failed to run Kubeclient: %{message}') % { message: e.message })
- rescue ActiveRecord::RecordInvalid => e
- log_service_error(e.class.name, provider.id, e.message)
- provider.make_errored!(s_('ClusterIntegration|Failed to configure Google Kubernetes Engine Cluster: %{message}') % { message: e.message })
- end
-
- private
-
- def create_gitlab_service_account!
- Clusters::Kubernetes::CreateOrUpdateServiceAccountService.gitlab_creator(
- kube_client,
- rbac: create_rbac_cluster?
- ).execute
- end
-
- def configure_provider
- provider.endpoint = gke_cluster.endpoint
- provider.status_event = :make_created
- end
-
- def configure_kubernetes
- cluster.platform_type = :kubernetes
- cluster.build_platform_kubernetes(
- api_url: 'https://' + gke_cluster.endpoint,
- ca_cert: Base64.decode64(gke_cluster.master_auth.cluster_ca_certificate),
- authorization_type: authorization_type,
- token: request_kubernetes_token)
- end
-
- def configure_pre_installed_knative
- knative = cluster.build_application_knative(
- hostname: 'example.com'
- )
- knative.make_pre_installed!
- end
-
- def request_kubernetes_token
- Clusters::Kubernetes::FetchKubernetesTokenService.new(
- kube_client,
- Clusters::Kubernetes::GITLAB_ADMIN_TOKEN_NAME,
- Clusters::Kubernetes::GITLAB_SERVICE_ACCOUNT_NAMESPACE
- ).execute
- end
-
- def authorization_type
- create_rbac_cluster? ? 'rbac' : 'abac'
- end
-
- def create_rbac_cluster?
- !provider.legacy_abac?
- end
-
- def kube_client
- @kube_client ||= build_kube_client!(
- 'https://' + gke_cluster.endpoint,
- Base64.decode64(gke_cluster.master_auth.cluster_ca_certificate)
- )
- end
-
- def build_kube_client!(api_url, ca_pem)
- raise "Incomplete settings" unless api_url
-
- Gitlab::Kubernetes::KubeClient.new(
- api_url,
- auth_options: { bearer_token: provider.access_token },
- ssl_options: kubeclient_ssl_options(ca_pem),
- http_proxy_uri: ENV['http_proxy']
- )
- end
-
- def kubeclient_ssl_options(ca_pem)
- opts = { verify_ssl: OpenSSL::SSL::VERIFY_PEER }
-
- if ca_pem.present?
- opts[:cert_store] = OpenSSL::X509::Store.new
- opts[:cert_store].add_cert(OpenSSL::X509::Certificate.new(ca_pem))
- end
-
- opts
- end
-
- def gke_cluster
- @gke_cluster ||= provider.api_client.projects_zones_clusters_get(
- provider.gcp_project_id,
- provider.zone,
- cluster.name)
- end
-
- def cluster
- @cluster ||= provider.cluster
- end
-
- def logger
- @logger ||= Gitlab::Kubernetes::Logger.build
- end
-
- def log_service_error(exception, provider_id, message)
- logger.error(
- exception: exception.class.name,
- service: self.class.name,
- provider_id: provider_id,
- message: message
- )
- end
- end
- end
-end
diff --git a/app/services/clusters/gcp/provision_service.rb b/app/services/clusters/gcp/provision_service.rb
deleted file mode 100644
index 7dc2d3c32f1..00000000000
--- a/app/services/clusters/gcp/provision_service.rb
+++ /dev/null
@@ -1,56 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Gcp
- class ProvisionService
- CLOUD_RUN_ADDONS = %i[http_load_balancing istio_config cloud_run_config].freeze
-
- attr_reader :provider
-
- def execute(provider)
- @provider = provider
-
- get_operation_id do |operation_id|
- if provider.make_creating(operation_id)
- WaitForClusterCreationWorker.perform_in(
- Clusters::Gcp::VerifyProvisionStatusService::INITIAL_INTERVAL,
- provider.cluster_id)
- else
- provider.make_errored!("Failed to update provider record; #{provider.errors}")
- end
- end
- end
-
- private
-
- def get_operation_id
- enable_addons = provider.cloud_run? ? CLOUD_RUN_ADDONS : []
-
- operation = provider.api_client.projects_zones_clusters_create(
- provider.gcp_project_id,
- provider.zone,
- provider.cluster.name,
- provider.num_nodes,
- machine_type: provider.machine_type,
- legacy_abac: provider.legacy_abac,
- enable_addons: enable_addons
- )
-
- unless operation.status == 'PENDING' || operation.status == 'RUNNING'
- return provider.make_errored!("Operation status is unexpected; #{operation.status_message}")
- end
-
- operation_id = provider.api_client.parse_operation_id(operation.self_link)
-
- unless operation_id
- return provider.make_errored!('Can not find operation_id from self_link')
- end
-
- yield(operation_id)
-
- rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e
- provider.make_errored!("Failed to request to CloudPlatform; #{e.message}")
- end
- end
- end
-end
diff --git a/app/services/clusters/gcp/verify_provision_status_service.rb b/app/services/clusters/gcp/verify_provision_status_service.rb
deleted file mode 100644
index ddb2832aae6..00000000000
--- a/app/services/clusters/gcp/verify_provision_status_service.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Gcp
- class VerifyProvisionStatusService
- attr_reader :provider
-
- INITIAL_INTERVAL = 2.minutes
- EAGER_INTERVAL = 10.seconds
- TIMEOUT = 20.minutes
-
- def execute(provider)
- @provider = provider
-
- request_operation do |operation|
- case operation.status
- when 'PENDING', 'RUNNING'
- continue_creation(operation)
- when 'DONE'
- finalize_creation
- else
- provider.make_errored!("Unexpected operation status; #{operation.status} #{operation.status_message}")
- end
- end
- end
-
- private
-
- def continue_creation(operation)
- if elapsed_time_from_creation(operation) < TIMEOUT
- WaitForClusterCreationWorker.perform_in(EAGER_INTERVAL, provider.cluster_id)
- else
- provider.make_errored!(_('Kubernetes cluster creation time exceeds timeout; %{timeout}') % { timeout: TIMEOUT })
- end
- end
-
- def elapsed_time_from_creation(operation)
- Time.current.utc - operation.start_time.to_time.utc
- end
-
- def finalize_creation
- Clusters::Gcp::FinalizeCreationService.new.execute(provider)
- end
-
- def request_operation(&blk)
- Clusters::Gcp::FetchOperationService.new.execute(provider, &blk)
- end
- end
- end
-end
diff --git a/app/services/concerns/integrations/project_test_data.rb b/app/services/concerns/integrations/project_test_data.rb
index ae1e1d1e66c..b3427697052 100644
--- a/app/services/concerns/integrations/project_test_data.rb
+++ b/app/services/concerns/integrations/project_test_data.rb
@@ -2,8 +2,14 @@
module Integrations
module ProjectTestData
+ NoDataError = Class.new(ArgumentError)
+
private
+ def no_data_error(msg)
+ raise NoDataError, msg
+ end
+
def push_events_data
Gitlab::DataBuilder::Push.build_sample(project, current_user)
end
@@ -11,7 +17,7 @@ module Integrations
def note_events_data
note = NotesFinder.new(current_user, project: project, target: project, sort: 'id_desc').execute.first
- return { error: s_('TestHooks|Ensure the project has notes.') } unless note.present?
+ no_data_error(s_('TestHooks|Ensure the project has notes.')) unless note.present?
Gitlab::DataBuilder::Note.build(note, current_user)
end
@@ -19,7 +25,7 @@ module Integrations
def issues_events_data
issue = IssuesFinder.new(current_user, project_id: project.id, sort: 'created_desc').execute.first
- return { error: s_('TestHooks|Ensure the project has issues.') } unless issue.present?
+ no_data_error(s_('TestHooks|Ensure the project has issues.')) unless issue.present?
issue.to_hook_data(current_user)
end
@@ -27,7 +33,7 @@ module Integrations
def merge_requests_events_data
merge_request = MergeRequestsFinder.new(current_user, project_id: project.id, sort: 'created_desc').execute.first
- return { error: s_('TestHooks|Ensure the project has merge requests.') } unless merge_request.present?
+ no_data_error(s_('TestHooks|Ensure the project has merge requests.')) unless merge_request.present?
merge_request.to_hook_data(current_user)
end
@@ -35,7 +41,7 @@ module Integrations
def job_events_data
build = Ci::JobsFinder.new(current_user: current_user, project: project).execute.first
- return { error: s_('TestHooks|Ensure the project has CI jobs.') } unless build.present?
+ no_data_error(s_('TestHooks|Ensure the project has CI jobs.')) unless build.present?
Gitlab::DataBuilder::Build.build(build)
end
@@ -43,7 +49,7 @@ module Integrations
def pipeline_events_data
pipeline = Ci::PipelinesFinder.new(project, current_user, order_by: 'id', sort: 'desc').execute.first
- return { error: s_('TestHooks|Ensure the project has CI pipelines.') } unless pipeline.present?
+ no_data_error(s_('TestHooks|Ensure the project has CI pipelines.')) unless pipeline.present?
Gitlab::DataBuilder::Pipeline.build(pipeline)
end
@@ -51,9 +57,7 @@ module Integrations
def wiki_page_events_data
page = project.wiki.list_pages(limit: 1).first
- if !project.wiki_enabled? || page.blank?
- return { error: s_('TestHooks|Ensure the wiki is enabled and has pages.') }
- end
+ no_data_error(s_('TestHooks|Ensure the wiki is enabled and has pages.')) if !project.wiki_enabled? || page.blank?
Gitlab::DataBuilder::WikiPage.build(page, current_user, 'create')
end
@@ -61,7 +65,7 @@ module Integrations
def deployment_events_data
deployment = DeploymentsFinder.new(project: project, order_by: 'created_at', sort: 'desc').execute.first
- return { error: s_('TestHooks|Ensure the project has deployments.') } unless deployment.present?
+ no_data_error(s_('TestHooks|Ensure the project has deployments.')) unless deployment.present?
Gitlab::DataBuilder::Deployment.build(deployment, deployment.status, Time.current)
end
@@ -69,7 +73,7 @@ module Integrations
def releases_events_data
release = ReleasesFinder.new(project, current_user, order_by: :created_at, sort: :desc).execute.first
- return { error: s_('TestHooks|Ensure the project has releases.') } unless release.present?
+ no_data_error(s_('TestHooks|Ensure the project has releases.')) unless release.present?
release.to_hook_data('create')
end
diff --git a/app/services/design_management/save_designs_service.rb b/app/services/design_management/save_designs_service.rb
index 64537293e65..ea5675c6ddd 100644
--- a/app/services/design_management/save_designs_service.rb
+++ b/app/services/design_management/save_designs_service.rb
@@ -113,7 +113,7 @@ module DesignManagement
def file_content(file, full_path)
transformer = ::Lfs::FileTransformer.new(project, repository, target_branch)
- transformer.new_file(full_path, file.to_io).content
+ transformer.new_file(full_path, file.to_io, detect_content_type: Feature.enabled?(:design_management_allow_dangerous_images, project)).content
end
# Returns the latest blobs for the designs as a Hash of `{ Design => Blob }`
diff --git a/app/services/discussions/resolve_service.rb b/app/services/discussions/resolve_service.rb
index baf14aa8a03..54fc452ac85 100644
--- a/app/services/discussions/resolve_service.rb
+++ b/app/services/discussions/resolve_service.rb
@@ -17,7 +17,8 @@ module Discussions
def execute
discussions.each(&method(:resolve_discussion))
- process_auto_merge
+
+ after_resolve_cleanup
end
private
@@ -67,9 +68,19 @@ module Discussions
end
end
- def process_auto_merge
+ def after_resolve_cleanup
return unless merge_request
return unless @resolved_count > 0
+
+ send_graphql_triggers
+ process_auto_merge
+ end
+
+ def send_graphql_triggers
+ GraphqlTriggers.merge_request_merge_status_updated(merge_request)
+ end
+
+ def process_auto_merge
return unless discussions_ready_to_merge?
AutoMergeProcessWorker.perform_async(merge_request.id)
diff --git a/app/services/discussions/unresolve_service.rb b/app/services/discussions/unresolve_service.rb
index fbd96ceafe7..f6685b79a33 100644
--- a/app/services/discussions/unresolve_service.rb
+++ b/app/services/discussions/unresolve_service.rb
@@ -12,10 +12,27 @@ module Discussions
end
def execute
+ @all_discussions_resolved_before = merge_request ? @discussion.noteable.discussions_resolved? : false
+
@discussion.unresolve!
+ send_graphql_triggers
+
Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter
.track_unresolve_thread_action(user: @user)
end
+
+ private
+
+ def merge_request
+ @discussion.noteable if @discussion.for_merge_request?
+ end
+ strong_memoize_attr :merge_request
+
+ def send_graphql_triggers
+ return unless merge_request && @all_discussions_resolved_before
+
+ GraphqlTriggers.merge_request_merge_status_updated(merge_request)
+ end
end
end
diff --git a/app/services/draft_notes/publish_service.rb b/app/services/draft_notes/publish_service.rb
index a82a6e22a5a..fab7a227e7d 100644
--- a/app/services/draft_notes/publish_service.rb
+++ b/app/services/draft_notes/publish_service.rb
@@ -34,7 +34,12 @@ module DraftNotes
created_notes = draft_notes.map do |draft_note|
draft_note.review = review
- create_note_from_draft(draft_note, skip_capture_diff_note_position: true, skip_keep_around_commits: true)
+ create_note_from_draft(
+ draft_note,
+ skip_capture_diff_note_position: true,
+ skip_keep_around_commits: true,
+ skip_merge_status_trigger: true
+ )
end
capture_diff_note_positions(created_notes)
@@ -43,16 +48,18 @@ module DraftNotes
set_reviewed
notification_service.async.new_review(review)
MergeRequests::ResolvedDiscussionNotificationService.new(project: project, current_user: current_user).execute(merge_request)
+ GraphqlTriggers.merge_request_merge_status_updated(merge_request)
end
- def create_note_from_draft(draft, skip_capture_diff_note_position: false, skip_keep_around_commits: false)
+ def create_note_from_draft(draft, skip_capture_diff_note_position: false, skip_keep_around_commits: false, skip_merge_status_trigger: false)
# Make sure the diff file is unfolded in order to find the correct line
# codes.
draft.diff_file&.unfold_diff_lines(draft.original_position)
note_params = draft.publish_params.merge(skip_keep_around_commits: skip_keep_around_commits)
note = Notes::CreateService.new(draft.project, draft.author, note_params).execute(
- skip_capture_diff_note_position: skip_capture_diff_note_position
+ skip_capture_diff_note_position: skip_capture_diff_note_position,
+ skip_merge_status_trigger: skip_merge_status_trigger
)
set_discussion_resolve_status(note, draft)
diff --git a/app/services/environments/stop_stale_service.rb b/app/services/environments/stop_stale_service.rb
new file mode 100644
index 00000000000..7b7032cb670
--- /dev/null
+++ b/app/services/environments/stop_stale_service.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Environments
+ class StopStaleService < BaseService
+ def execute
+ return ServiceResponse.error(message: 'Before date must be provided') unless params[:before].present?
+
+ return ServiceResponse.error(message: 'Unauthorized') unless can?(current_user, :stop_environment, project)
+
+ Environment.available
+ .deployed_and_updated_before(project.id, params[:before])
+ .without_protected(project)
+ .in_batches(of: 100) do |env_batch| # rubocop:disable Cop/InBatches
+ Environments::AutoStopWorker.bulk_perform_async_with_contexts(
+ env_batch,
+ arguments_proc: ->(environment) { environment.id },
+ context_proc: ->(environment) { { project: project } }
+ )
+ end
+
+ ServiceResponse.success(message: 'Successfully requested stop for all stale environments')
+ end
+ end
+end
diff --git a/app/services/files/base_service.rb b/app/services/files/base_service.rb
index 1055f5ff088..8f722de2019 100644
--- a/app/services/files/base_service.rb
+++ b/app/services/files/base_service.rb
@@ -9,7 +9,7 @@ module Files
git_user = Gitlab::Git::User.from_gitlab(current_user) if current_user.present?
- @author_email = params[:author_email] || git_user&.email
+ @author_email = commit_email(git_user)
@author_name = params[:author_name] || git_user&.name
@commit_message = params[:commit_message]
@last_commit_sha = params[:last_commit_sha]
@@ -33,5 +33,18 @@ module Files
last_commit.sha != commit_id
end
+
+ private
+
+ def commit_email(git_user)
+ return params[:author_email] if params[:author_email].present?
+ return unless current_user
+
+ namespace_commit_email = current_user.namespace_commit_email_for_project(@start_project)
+
+ return namespace_commit_email.email.email if namespace_commit_email
+
+ git_user.email
+ end
end
end
diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb
index 71dd9501648..6087efce9fd 100644
--- a/app/services/git/branch_hooks_service.rb
+++ b/app/services/git/branch_hooks_service.rb
@@ -165,14 +165,11 @@ module Git
end
def signature_types
- types = [
+ [
::CommitSignatures::GpgSignature,
- ::CommitSignatures::X509CommitSignature
+ ::CommitSignatures::X509CommitSignature,
+ ::CommitSignatures::SshSignature
]
-
- types.push(::CommitSignatures::SshSignature) if Feature.enabled?(:ssh_commit_signatures, project)
-
- types
end
def unsigned_commit_shas(commits)
diff --git a/app/services/groups/import_export/export_service.rb b/app/services/groups/import_export/export_service.rb
index bd54b48c5f4..2d88283661c 100644
--- a/app/services/groups/import_export/export_service.rb
+++ b/app/services/groups/import_export/export_service.rb
@@ -71,7 +71,7 @@ module Groups
end
def tree_exporter
- tree_exporter_class.new(
+ Gitlab::ImportExport::Group::TreeSaver.new(
group: group,
current_user: current_user,
shared: shared,
@@ -79,18 +79,6 @@ module Groups
)
end
- def tree_exporter_class
- if ndjson?
- Gitlab::ImportExport::Group::TreeSaver
- else
- Gitlab::ImportExport::Group::LegacyTreeSaver
- end
- end
-
- def ndjson?
- ::Feature.enabled?(:group_export_ndjson, group&.parent)
- end
-
def version_saver
Gitlab::ImportExport::VersionSaver.new(shared: shared)
end
diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb
index ac181245986..15948ab82a2 100644
--- a/app/services/groups/import_export/import_service.rb
+++ b/app/services/groups/import_export/import_service.rb
@@ -29,7 +29,7 @@ module Groups
def execute
Gitlab::Tracking.event(self.class.name, 'create', label: 'import_group_from_file')
- if valid_user_permissions? && import_file && restorers.all?(&:restore)
+ if valid_user_permissions? && import_file && valid_import_file? && restorers.all?(&:restore)
notify_success
Gitlab::Tracking.event(
@@ -75,25 +75,11 @@ module Groups
def tree_restorer
@tree_restorer ||=
- if ndjson?
- Gitlab::ImportExport::Group::TreeRestorer.new(
- user: current_user,
- shared: shared,
- group: group
- )
- else
- Gitlab::ImportExport::Group::LegacyTreeRestorer.new(
- user: current_user,
- shared: shared,
- group: group,
- group_hash: nil
- )
- end
- end
-
- def ndjson?
- ::Feature.enabled?(:group_import_ndjson, group&.parent) &&
- File.exist?(File.join(shared.export_path, 'tree/groups/_all.ndjson'))
+ Gitlab::ImportExport::Group::TreeRestorer.new(
+ user: current_user,
+ shared: shared,
+ group: group
+ )
end
def remove_import_file
@@ -115,6 +101,14 @@ module Groups
end
end
+ def valid_import_file?
+ return true if File.exist?(File.join(shared.export_path, 'tree/groups/_all.ndjson'))
+
+ shared.error(::Gitlab::ImportExport::Error.incompatible_import_file_error)
+
+ false
+ end
+
def notify_success
@logger.info(
group_id: group.id,
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index 6fbf7daeb81..0a9705181ba 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -34,6 +34,7 @@ module Groups
update_integrations
remove_issue_contacts(old_root_ancestor_id, was_root_group)
update_crm_objects(was_root_group)
+ remove_namespace_commit_emails(was_root_group)
end
post_update_hooks(@updated_project_ids, old_root_ancestor_id)
@@ -279,6 +280,10 @@ module Groups
Gitlab::EventStore.publish(event)
end
+
+ def remove_namespace_commit_emails(was_root_group)
+ Users::NamespaceCommitEmail.delete_for_namespace(@group) if was_root_group
+ end
end
end
diff --git a/app/services/ide/schemas_config_service.rb b/app/services/ide/schemas_config_service.rb
index a013a4679b5..5292f15dc37 100644
--- a/app/services/ide/schemas_config_service.rb
+++ b/app/services/ide/schemas_config_service.rb
@@ -33,9 +33,7 @@ module Ide
end
def predefined_schemas
- return PREDEFINED_SCHEMAS if Feature.enabled?(:schema_linting)
-
- []
+ PREDEFINED_SCHEMAS
end
def get_cached(url)
diff --git a/app/services/import/github/gists_import_service.rb b/app/services/import/github/gists_import_service.rb
index df1bbe306e7..e57430916fa 100644
--- a/app/services/import/github/gists_import_service.rb
+++ b/app/services/import/github/gists_import_service.rb
@@ -3,16 +3,20 @@
module Import
module Github
class GistsImportService < ::BaseService
- def initialize(user, params)
+ def initialize(user, client, params)
@current_user = user
@params = params
+ @client = client
end
def execute
return error('Import already in progress', 422) if import_status.started?
+ check_user_token
start_import
success
+ rescue Octokit::Unauthorized
+ error('Access denied to the GitHub account.', 401)
end
private
@@ -29,6 +33,10 @@ module Import
Gitlab::GithubGistsImport::StartImportWorker.perform_async(current_user.id, encrypted_token)
import_status.start!
end
+
+ def check_user_token
+ @client.octokit.user.present?
+ end
end
end
end
diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb
index 2378a4b11b1..b30c344723d 100644
--- a/app/services/import/github_service.rb
+++ b/app/services/import/github_service.rb
@@ -46,12 +46,8 @@ module Import
@project_name ||= params[:new_name].presence || repo[:name]
end
- def namespace_path
- @namespace_path ||= params[:target_namespace].presence || current_user.namespace_path
- end
-
def target_namespace
- @target_namespace ||= find_or_create_namespace(namespace_path, current_user.namespace_path)
+ @target_namespace ||= Namespace.find_by_full_path(target_namespace_path)
end
def extra_project_attrs
@@ -104,6 +100,8 @@ module Import
def validate_context
if blocked_url?
log_and_return_error("Invalid URL: #{url}", _("Invalid URL: %{url}") % { url: url }, :bad_request)
+ elsif target_namespace.nil?
+ error(_('Namespace or group to import repository into does not exist.'), :unprocessable_entity)
elsif !authorized?
error(_('This namespace has already been taken. Choose a different one.'), :unprocessable_entity)
elsif oversized?
@@ -111,6 +109,12 @@ module Import
end
end
+ def target_namespace_path
+ raise ArgumentError, 'Target namespace is required' if params[:target_namespace].blank?
+
+ params[:target_namespace]
+ end
+
def log_error(exception)
Gitlab::GithubImport::Logger.error(
message: 'Import failed due to a GitHub error',
diff --git a/app/services/integrations/test/base_service.rb b/app/services/integrations/test/base_service.rb
index a8a027092d5..6291f2dfbaa 100644
--- a/app/services/integrations/test/base_service.rb
+++ b/app/services/integrations/test/base_service.rb
@@ -21,9 +21,9 @@ module Integrations
return error('Testing not available for this event')
end
- return error(data[:error]) if data[:error].present?
-
integration.test(data)
+ rescue ArgumentError => e
+ error(e.message)
end
private
diff --git a/app/services/issuable/discussions_list_service.rb b/app/services/issuable/discussions_list_service.rb
index 1e5e37e4e1b..10e7660289b 100644
--- a/app/services/issuable/discussions_list_service.rb
+++ b/app/services/issuable/discussions_list_service.rb
@@ -19,7 +19,7 @@ module Issuable
return Note.none unless can_read_issuable_notes?
notes = NotesFinder.new(current_user, params.merge({ target: issuable, project: issuable.project }))
- .execute.with_web_entity_associations.inc_relations_for_view.fresh
+ .execute.with_web_entity_associations.inc_relations_for_view(issuable).fresh
if paginator
paginated_discussions_by_type = paginator.records.group_by(&:table_name)
@@ -49,7 +49,6 @@ module Issuable
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
diff --git a/app/services/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb
index 2e9775af8c2..f244f54b25f 100644
--- a/app/services/issuable_links/create_service.rb
+++ b/app/services/issuable_links/create_service.rb
@@ -121,7 +121,7 @@ module IssuableLinks
end
def target_issuable_type
- :issue
+ 'issue'
end
def create_notes(issuable_link)
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index 10407e99715..553fb6e2ac9 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -97,6 +97,16 @@ module Issues
hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks
issue.project.execute_hooks(issue_data, hooks_scope)
issue.project.execute_integrations(issue_data, hooks_scope)
+
+ execute_incident_hooks(issue, issue_data) if issue.incident?
+ end
+
+ # We can remove this code after proposal in
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/367550#proposal is updated.
+ def execute_incident_hooks(issue, issue_data)
+ issue_data[:object_kind] = 'incident'
+ issue_data[:event_type] = 'incident'
+ issue.project.execute_integrations(issue_data, :incident_hooks)
end
def update_project_counter_caches?(issue)
@@ -117,7 +127,7 @@ module Issues
override :allowed_create_params
def allowed_create_params(params)
- super(params).except(:issue_type, :work_item_type_id, :work_item_type)
+ super(params).except(:work_item_type_id, :work_item_type)
end
end
end
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index afad8d0c6bf..f6a1db2dcaa 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -24,6 +24,9 @@ module Issues
return error(_('Operation not allowed'), 403) unless @current_user.can?(authorization_action, @project)
@issue = @build_service.execute
+ # issue_type is set in BuildService, so we can delete it from params, in later phase
+ # it can be set also from quick actions - in that case work_item_id is synced later again
+ params.delete(:issue_type)
handle_move_between_ids(@issue)
@@ -68,6 +71,7 @@ module Issues
handle_escalation_status_change(issue)
create_timeline_event(issue)
try_to_associate_contacts(issue)
+ change_additional_attributes(issue)
super
end
@@ -127,6 +131,15 @@ module Issues
set_crm_contacts(issue, contacts)
end
+
+ override :change_additional_attributes
+ def change_additional_attributes(issue)
+ super
+
+ # issue_type can be still set through quick actions, in that case
+ # we have to make sure to re-sync work_item_type with it
+ issue.work_item_type_id = find_work_item_type_id(params[:issue_type]) if params[:issue_type]
+ end
end
end
diff --git a/app/services/lfs/file_transformer.rb b/app/services/lfs/file_transformer.rb
index 69d33e1c873..a02fce552cf 100644
--- a/app/services/lfs/file_transformer.rb
+++ b/app/services/lfs/file_transformer.rb
@@ -29,11 +29,11 @@ module Lfs
@branch_name = branch_name
end
- def new_file(file_path, file_content, encoding: nil)
+ def new_file(file_path, file_content, encoding: nil, detect_content_type: false)
if project.lfs_enabled? && lfs_file?(file_path)
file_content = parse_file_content(file_content, encoding: encoding)
lfs_pointer_file = Gitlab::Git::LfsPointerFile.new(file_content)
- lfs_object = create_lfs_object!(lfs_pointer_file, file_content)
+ lfs_object = create_lfs_object!(lfs_pointer_file, file_content, detect_content_type)
link_lfs_object!(lfs_object)
@@ -63,9 +63,17 @@ module Lfs
end
# rubocop: disable CodeReuse/ActiveRecord
- def create_lfs_object!(lfs_pointer_file, file_content)
+ def create_lfs_object!(lfs_pointer_file, file_content, detect_content_type)
LfsObject.find_or_create_by(oid: lfs_pointer_file.sha256, size: lfs_pointer_file.size) do |lfs_object|
- lfs_object.file = CarrierWaveStringFile.new(file_content)
+ lfs_object.file = if detect_content_type && (content_type = Gitlab::Utils::MimeType.from_string(file_content))
+ CarrierWaveStringFile.new_file(
+ file_content: file_content,
+ filename: '',
+ content_type: content_type
+ )
+ else
+ CarrierWaveStringFile.new(file_content)
+ end
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/members/approve_access_request_service.rb b/app/services/members/approve_access_request_service.rb
index 51f9492ec91..5e73d7a957b 100644
--- a/app/services/members/approve_access_request_service.rb
+++ b/app/services/members/approve_access_request_service.rb
@@ -6,7 +6,7 @@ module Members
validate_access!(access_requester) unless skip_authorization
access_requester.access_level = params[:access_level] if params[:access_level]
- access_requester.accept_request
+ access_requester.accept_request(current_user)
after_execute(member: access_requester, skip_log_audit_event: skip_log_audit_event)
diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb
index f59a3ed77eb..2d378a64c02 100644
--- a/app/services/members/creator_service.rb
+++ b/app/services/members/creator_service.rb
@@ -225,7 +225,7 @@ module Members
access_level: access_level)
.execute(
member,
- skip_authorization: ldap,
+ skip_authorization: ldap || skip_authorization?,
skip_log_audit_event: ldap
)
end
diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb
index 5afc13701e0..24c5b12b335 100644
--- a/app/services/members/destroy_service.rb
+++ b/app/services/members/destroy_service.rb
@@ -13,24 +13,48 @@ module Members
end
@skip_auth = skip_authorization
- last_owner = true
+
+ if a_group_owner?(member)
+ process_destroy_of_group_owner_member(member, skip_subresources, unassign_issuables)
+ else
+ destroy_member(member)
+ destroy_data_related_to_member(member, skip_subresources, unassign_issuables)
+ end
+
+ member
+ end
+
+ private
+
+ def process_destroy_of_group_owner_member(member, skip_subresources, unassign_issuables)
+ # Deleting 2 different group owners via the API in quick succession could lead to
+ # wrong results for the `last_owner?` check due to race conditions. To prevent this
+ # we wrap both the last_owner? check and the deletes of owners within a lock.
+ last_group_owner = true
in_lock("delete_members:#{member.source.class}:#{member.source.id}", sleep_sec: 0.1.seconds) do
- break if member.is_a?(GroupMember) && member.source.last_owner?(member.user)
+ break if member.source.last_owner?(member.user)
- last_owner = false
- member.destroy
+ last_group_owner = false
+ destroy_member(member)
end
- unless last_owner
- member.user&.invalidate_cache_counts
- delete_member_associations(member, skip_subresources, unassign_issuables)
- end
+ # deletion of related data does not have to be within the lock.
+ destroy_data_related_to_member(member, skip_subresources, unassign_issuables) unless last_group_owner
+ end
- member
+ def destroy_member(member)
+ member.destroy
end
- private
+ def destroy_data_related_to_member(member, skip_subresources, unassign_issuables)
+ member.user&.invalidate_cache_counts
+ delete_member_associations(member, skip_subresources, unassign_issuables)
+ end
+
+ def a_group_owner?(member)
+ member.is_a?(GroupMember) && member.owner?
+ end
def delete_member_associations(member, skip_subresources, unassign_issuables)
if member.request? && member.user != current_user
diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb
index 0e6b02f7a80..b2c0fffc12d 100644
--- a/app/services/members/update_service.rb
+++ b/app/services/members/update_service.rb
@@ -11,10 +11,9 @@ module Members
[member.id, { human_access: member.human_access, expires_at: member.expires_at }]
end
- if Feature.enabled?(:bulk_update_membership_roles, current_user)
- multiple_members_update(members, permission, old_access_level_expiry_map)
- else
- single_member_update(members.first, permission, old_access_level_expiry_map)
+ updated_members = update_members(members, permission)
+ Member.transaction do
+ updated_members.each { |member| post_update(member, permission, old_access_level_expiry_map) }
end
prepare_response(members)
@@ -22,35 +21,22 @@ module Members
private
- def single_member_update(member, permission, old_access_level_expiry_map)
+ def update_members(members, permission)
+ # `filter_map` avoids the `post_update` call for the member that resulted in no change
+ Member.transaction do
+ members.filter_map { |member| update_member(member, permission) }
+ end
+ rescue ActiveRecord::RecordInvalid
+ []
+ end
+
+ def update_member(member, permission)
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
+ return unless member.changed?
- updated_members.each { |member| post_update(member, permission, old_access_level_expiry_map) }
+ member.tap(&:save!)
end
def post_update(member, permission, old_access_level_expiry_map)
@@ -62,18 +48,13 @@ module Members
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 })
+ errored_members = members.select { |member| member.errors.any? }
+ if errored_members.present?
+ error_message = errored_members.flat_map { |member| member.errors.full_messages }.uniq.to_sentence
+ return error(error_message, pass_back: { members: errored_members })
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
+ success(members: members)
end
def has_update_permissions?(member, permission)
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 468cadb03c7..f6cbe889128 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -94,6 +94,10 @@ module MergeRequests
private
+ def refresh_pipelines_on_merge_requests(merge_request, allow_duplicate: false)
+ create_pipeline_for(merge_request, current_user, async: true, allow_duplicate: allow_duplicate)
+ end
+
def enqueue_jira_connect_messages_for(merge_request)
return unless project.jira_subscription_exists?
@@ -184,16 +188,18 @@ module MergeRequests
merge_request, merge_request.project, current_user, old_reviewers)
end
- def create_pipeline_for(merge_request, user, async: false)
+ def create_pipeline_for(merge_request, user, async: false, allow_duplicate: false)
+ create_pipeline_params = params.slice(:push_options).merge(allow_duplicate: allow_duplicate)
+
if async
MergeRequests::CreatePipelineWorker.perform_async(
project.id,
user.id,
merge_request.id,
- params.slice(:push_options).deep_stringify_keys)
+ create_pipeline_params.deep_stringify_keys)
else
MergeRequests::CreatePipelineService
- .new(project: project, current_user: user, params: params.slice(:push_options))
+ .new(project: project, current_user: user, params: create_pipeline_params)
.execute(merge_request)
end
end
diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb
index 1c4e1784b34..792f1728b88 100644
--- a/app/services/merge_requests/rebase_service.rb
+++ b/app/services/merge_requests/rebase_service.rb
@@ -6,6 +6,19 @@ module MergeRequests
attr_reader :merge_request, :rebase_error
+ def validate(merge_request)
+ return error_response(_('Source branch does not exist')) unless
+ merge_request.source_branch_exists?
+
+ return error_response(_('Cannot push to source branch')) unless
+ user_access.can_push_to_branch?(merge_request.source_branch)
+
+ return error_response(_('Source branch is protected from force push')) unless
+ merge_request.permits_force_push?
+
+ ServiceResponse.success
+ end
+
def execute(merge_request, skip_ci: false)
@merge_request = merge_request
@skip_ci = skip_ci
@@ -40,5 +53,13 @@ module MergeRequests
REBASE_ERROR
end
end
+
+ def user_access
+ Gitlab::UserAccess.new(current_user, container: project)
+ end
+
+ def error_response(message)
+ ServiceResponse.error(message: message)
+ end
end
end
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index 533d0052fb8..ce49d5dd43c 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -162,10 +162,6 @@ module MergeRequests
@outdate_service ||= Suggestions::OutdateService.new
end
- def refresh_pipelines_on_merge_requests(merge_request)
- create_pipeline_for(merge_request, current_user, async: true)
- end
-
def abort_auto_merges(merge_request)
abort_auto_merge(merge_request, 'source branch was updated')
end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 745647b727c..a273b853c0d 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -169,6 +169,8 @@ module MergeRequests
merge_request.target_branch
)
+ refresh_pipelines_on_merge_requests(merge_request, allow_duplicate: true)
+
abort_auto_merge(merge_request, 'target branch was changed')
end
diff --git a/app/services/ml/experiment_tracking/candidate_repository.rb b/app/services/ml/experiment_tracking/candidate_repository.rb
index 1dbeb30145b..f1fd93d7816 100644
--- a/app/services/ml/experiment_tracking/candidate_repository.rb
+++ b/app/services/ml/experiment_tracking/candidate_repository.rb
@@ -14,9 +14,10 @@ module Ml
::Ml::Candidate.with_project_id_and_iid(project.id, iid)
end
- def create!(experiment, start_time, tags = nil)
+ def create!(experiment, start_time, tags = nil, name = nil)
candidate = experiment.candidates.create!(
user: user,
+ name: candidate_name(name, tags),
start_time: start_time || 0
)
@@ -85,6 +86,13 @@ module Ml
entity_class.insert_all(entities, returning: false) unless entities.empty?
end
+
+ def candidate_name(name, tags)
+ return name if name.present?
+ return unless tags.present?
+
+ tags.detect { |t| t[:key] == 'mlflow.runName' }&.dig(:value)
+ end
end
end
end
diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb
index cc5c81cf280..e6766273441 100644
--- a/app/services/notes/build_service.rb
+++ b/app/services/notes/build_service.rb
@@ -35,7 +35,7 @@ module Notes
note.author = current_user
parent_confidential = discussion&.confidential?
- can_set_confidential = can?(current_user, :mark_note_as_confidential, note)
+ can_set_confidential = can?(current_user, :mark_note_as_internal, note)
return discussion_not_found if parent_confidential && !can_set_confidential
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 555d60dc291..5f05b613288 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -4,7 +4,7 @@ module Notes
class CreateService < ::Notes::BaseService
include IncidentManagement::UsageData
- def execute(skip_capture_diff_note_position: false)
+ def execute(skip_capture_diff_note_position: false, skip_merge_status_trigger: false)
note = Notes::BuildService.new(project, current_user, params.except(:merge_request_diff_head_sha)).execute
# n+1: https://gitlab.com/gitlab-org/gitlab-foss/issues/37440
@@ -34,7 +34,13 @@ module Notes
end
end
- when_saved(note, skip_capture_diff_note_position: skip_capture_diff_note_position) if note_saved
+ if note_saved
+ when_saved(
+ note,
+ skip_capture_diff_note_position: skip_capture_diff_note_position,
+ skip_merge_status_trigger: skip_merge_status_trigger
+ )
+ end
end
note
@@ -72,15 +78,21 @@ module Notes
end
end
- def when_saved(note, skip_capture_diff_note_position: false)
+ def when_saved(note, skip_capture_diff_note_position: false, skip_merge_status_trigger: false)
todo_service.new_note(note, current_user)
clear_noteable_diffs_cache(note)
Suggestions::CreateService.new(note).execute
increment_usage_counter(note)
track_event(note, current_user)
- if !skip_capture_diff_note_position && note.for_merge_request? && note.diff_note? && note.start_of_discussion?
- Discussions::CaptureDiffNotePositionService.new(note.noteable, note.diff_file&.paths).execute(note.discussion)
+ if note.for_merge_request? && note.start_of_discussion?
+ if !skip_capture_diff_note_position && note.diff_note?
+ Discussions::CaptureDiffNotePositionService.new(note.noteable, note.diff_file&.paths).execute(note.discussion)
+ end
+
+ if !skip_merge_status_trigger && note.to_be_resolved?
+ GraphqlTriggers.merge_request_merge_status_updated(note.noteable)
+ end
end
end
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 550bd6d4c55..777d02c590d 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -91,10 +91,10 @@ class NotificationService
end
# Notify the user when at least one of their personal access tokens has expired today
- def access_token_expired(user)
+ def access_token_expired(user, token_names = [])
return unless user.can?(:receive_notifications)
- mailer.access_token_expired_email(user).deliver_later
+ mailer.access_token_expired_email(user, token_names).deliver_later
end
# Notify the user when one of their personal access tokens is revoked
diff --git a/app/services/pages_domains/create_service.rb b/app/services/pages_domains/create_service.rb
index 1f771ca3a05..17194fbe5e4 100644
--- a/app/services/pages_domains/create_service.rb
+++ b/app/services/pages_domains/create_service.rb
@@ -24,6 +24,7 @@ module PagesDomains
project_id: project.id,
namespace_id: project.namespace_id,
root_namespace_id: project.root_namespace.id,
+ domain_id: domain.id,
domain: domain.domain
}
)
diff --git a/app/services/pages_domains/delete_service.rb b/app/services/pages_domains/delete_service.rb
index af69e1845a9..89e598acee0 100644
--- a/app/services/pages_domains/delete_service.rb
+++ b/app/services/pages_domains/delete_service.rb
@@ -22,6 +22,7 @@ module PagesDomains
project_id: project.id,
namespace_id: project.namespace_id,
root_namespace_id: project.root_namespace.id,
+ domain_id: domain.id,
domain: domain.domain
}
)
diff --git a/app/services/pages_domains/retry_acme_order_service.rb b/app/services/pages_domains/retry_acme_order_service.rb
index 6251c9d3615..01647a8ecf5 100644
--- a/app/services/pages_domains/retry_acme_order_service.rb
+++ b/app/services/pages_domains/retry_acme_order_service.rb
@@ -30,6 +30,7 @@ module PagesDomains
project_id: domain.project.id,
namespace_id: domain.project.namespace_id,
root_namespace_id: domain.project.root_namespace.id,
+ domain_id: domain.id,
domain: domain.domain
}
)
diff --git a/app/services/pages_domains/update_service.rb b/app/services/pages_domains/update_service.rb
index b038aaa95b6..1887441d8b8 100644
--- a/app/services/pages_domains/update_service.rb
+++ b/app/services/pages_domains/update_service.rb
@@ -24,6 +24,7 @@ module PagesDomains
project_id: project.id,
namespace_id: project.namespace_id,
root_namespace_id: project.root_namespace.id,
+ domain_id: domain.id,
domain: domain.domain
}
)
diff --git a/app/services/personal_access_tokens/revoke_service.rb b/app/services/personal_access_tokens/revoke_service.rb
index bb5edc27340..237c95bc456 100644
--- a/app/services/personal_access_tokens/revoke_service.rb
+++ b/app/services/personal_access_tokens/revoke_service.rb
@@ -4,13 +4,17 @@ module PersonalAccessTokens
class RevokeService < BaseService
attr_reader :token, :current_user, :group
- VALID_SOURCES = %w[secret_detection].freeze
+ VALID_SOURCES = %i[self secret_detection].freeze
def initialize(current_user = nil, token: nil, group: nil, source: nil)
@current_user = current_user
@token = token
@group = group
@source = source
+
+ @source = :self if @current_user && !@source
+
+ raise ArgumentError unless VALID_SOURCES.include?(@source)
end
def execute
@@ -36,22 +40,21 @@ module PersonalAccessTokens
end
def revocation_permitted?
- if current_user
+ case @source
+ when :self
Ability.allowed?(current_user, :revoke_token, token)
+ when :secret_detection
+ true
else
- source && VALID_SOURCES.include?(source)
+ false
end
end
- def source
- current_user&.username || @source
- end
-
def log_event
Gitlab::AppLogger.info(
class: self.class.name,
message: "PAT Revoked",
- revoked_by: source,
+ revoked_by: current_user&.username || @source,
revoked_for: token.user.username,
token_id: token.id)
end
diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb
index ae5aae87a77..11437ad90fc 100644
--- a/app/services/projects/autocomplete_service.rb
+++ b/app/services/projects/autocomplete_service.rb
@@ -23,7 +23,7 @@ module Projects
MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title])
end
- def commands(noteable, type)
+ def commands(noteable)
return [] unless noteable && current_user
QuickActions::InterpretService.new(project, current_user).available_commands(noteable)
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index a4b473f35c6..d3313526eaf 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -22,6 +22,12 @@ module Projects
end
def execute
+ params[:wiki_enabled] = params[:wiki_access_level] if params[:wiki_access_level]
+ params[:builds_enabled] = params[:builds_access_level] if params[:builds_access_level]
+ params[:snippets_enabled] = params[:builds_access_level] if params[:snippets_access_level]
+ params[:merge_requests_enabled] = params[:merge_requests_access_level] if params[:merge_requests_access_level]
+ params[:issues_enabled] = params[:issues_access_level] if params[:issues_access_level]
+
if create_from_template?
return ::Projects::CreateFromTemplateService.new(current_user, params).execute
end
diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb
index 967a1e990b2..e6ccae0a22b 100644
--- a/app/services/projects/import_service.rb
+++ b/app/services/projects/import_service.rb
@@ -166,7 +166,7 @@ module Projects
.then do |(import_url, resolved_host)|
next '' if resolved_host.nil? || !import_url.scheme.in?(%w[http https])
- import_url.host.to_s
+ import_url.hostname.to_s
end
end
diff --git a/app/services/projects/refresh_build_artifacts_size_statistics_service.rb b/app/services/projects/refresh_build_artifacts_size_statistics_service.rb
index 8e006dc8c34..f11083d6c04 100644
--- a/app/services/projects/refresh_build_artifacts_size_statistics_service.rb
+++ b/app/services/projects/refresh_build_artifacts_size_statistics_service.rb
@@ -2,28 +2,31 @@
module Projects
class RefreshBuildArtifactsSizeStatisticsService
- BATCH_SIZE = 1000
+ BATCH_SIZE = 500
+ REFRESH_INTERVAL_SECONDS = 0.1
def execute
refresh = Projects::BuildArtifactsSizeRefresh.process_next_refresh!
- return unless refresh
+
+ return unless refresh&.running?
batch = refresh.next_batch(limit: BATCH_SIZE).to_a
if batch.any?
- # We are doing the sum in ruby because the query takes too long when done in SQL
- total_artifacts_size = batch.sum { |artifact| artifact.size.to_i }
+ increments = batch.map do |artifact|
+ Gitlab::Counters::Increment.new(amount: artifact.size.to_i, ref: artifact.id)
+ end
Projects::BuildArtifactsSizeRefresh.transaction do
# Mark the refresh ready for another worker to pick up and process the next batch
refresh.requeue!(batch.last.id)
- refresh.project.statistics.increment_counter(:build_artifacts_size, total_artifacts_size)
+ ProjectStatistics.bulk_increment_statistic(refresh.project, :build_artifacts_size, increments)
end
+
+ sleep REFRESH_INTERVAL_SECONDS
else
- # Remove the refresh job from the table if there are no more
- # remaining job artifacts to calculate for the given project.
- refresh.destroy!
+ refresh.schedule_finalize!
end
refresh
diff --git a/app/services/repositories/housekeeping_service.rb b/app/services/repositories/housekeeping_service.rb
index e12d69807f2..f314e210442 100644
--- a/app/services/repositories/housekeeping_service.rb
+++ b/app/services/repositories/housekeeping_service.rb
@@ -10,7 +10,7 @@ module Repositories
class HousekeepingService < BaseService
# Timeout set to 24h
LEASE_TIMEOUT = 86400
- PACK_REFS_PERIOD = 6
+ GC_PERIOD = 200
class LeaseTaken < StandardError
def to_s
@@ -74,21 +74,13 @@ module Repositories
if pushes_since_gc % gc_period == 0
:gc
- elsif pushes_since_gc % full_repack_period == 0
- :full_repack
- elsif pushes_since_gc % repack_period == 0
- :incremental_repack
else
- :pack_refs
+ :incremental_repack
end
end
def period_match?
- if Feature.enabled?(:optimized_housekeeping)
- pushes_since_gc % repack_period == 0
- else
- [gc_period, full_repack_period, repack_period, PACK_REFS_PERIOD].any? { |period| pushes_since_gc % period == 0 }
- end
+ [gc_period, repack_period].any? { |period| pushes_since_gc % period == 0 }
end
def housekeeping_enabled?
@@ -96,11 +88,7 @@ module Repositories
end
def gc_period
- Gitlab::CurrentSettings.housekeeping_gc_period
- end
-
- def full_repack_period
- Gitlab::CurrentSettings.housekeeping_full_repack_period
+ GC_PERIOD
end
def repack_period
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 403a2f077b0..b4344a009b2 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -112,6 +112,25 @@ class SearchService
false
end
+ def global_search_enabled_for_scope?
+ case params[:scope]
+ when 'blobs'
+ Feature.enabled?(:global_search_code_tab, current_user, type: :ops)
+ when 'commits'
+ Feature.enabled?(:global_search_commits_tab, current_user, type: :ops)
+ when 'issues'
+ Feature.enabled?(:global_search_issues_tab, current_user, type: :ops)
+ when 'merge_requests'
+ Feature.enabled?(:global_search_merge_requests_tab, current_user, type: :ops)
+ when 'wiki_blobs'
+ Feature.enabled?(:global_search_wiki_tab, current_user, type: :ops)
+ when 'users'
+ Feature.enabled?(:global_search_users_tab, current_user, type: :ops)
+ else
+ true
+ end
+ end
+
private
def page
diff --git a/app/services/security/ci_configuration/base_create_service.rb b/app/services/security/ci_configuration/base_create_service.rb
index 7f3b66d40e1..aaa850fde39 100644
--- a/app/services/security/ci_configuration/base_create_service.rb
+++ b/app/services/security/ci_configuration/base_create_service.rb
@@ -3,7 +3,7 @@
module Security
module CiConfiguration
class BaseCreateService
- attr_reader :branch_name, :current_user, :project
+ attr_reader :branch_name, :current_user, :project, :name
def initialize(project, current_user)
@project = project
@@ -41,8 +41,18 @@ module Security
end
def existing_gitlab_ci_content
- @gitlab_ci_yml ||= project.ci_config_for(project.repository.root_ref_sha)
+ root_ref = root_ref_sha(project)
+ return if root_ref.nil?
+
+ @gitlab_ci_yml ||= project.ci_config_for(root_ref)
YAML.safe_load(@gitlab_ci_yml) if @gitlab_ci_yml
+ rescue Psych::BadAlias
+ raise Gitlab::Graphql::Errors::MutationError,
+ ".gitlab-ci.yml with aliases/anchors is not supported. Please change the CI configuration manually."
+ rescue Psych::Exception => e
+ Gitlab::AppLogger.error("Failed to process existing .gitlab-ci.yml: #{e.message}")
+ raise Gitlab::Graphql::Errors::MutationError,
+ "#{name} merge request creation mutation failed"
end
def successful_change_path
@@ -61,6 +71,15 @@ module Security
self.class.to_s, action[:action], label: action[:default_values_overwritten].to_s
)
end
+
+ def root_ref_sha(project)
+ project.repository.root_ref_sha
+ rescue StandardError => e
+ # this might fail on the very first commit,
+ # and unfortunately it raises a StandardError
+ Gitlab::ErrorTracking.track_exception(e, project_id: project.id)
+ nil
+ end
end
end
end
diff --git a/app/services/security/ci_configuration/container_scanning_create_service.rb b/app/services/security/ci_configuration/container_scanning_create_service.rb
index da2f1ac0981..4dfd05451ad 100644
--- a/app/services/security/ci_configuration/container_scanning_create_service.rb
+++ b/app/services/security/ci_configuration/container_scanning_create_service.rb
@@ -21,6 +21,10 @@ module Security
def description
_('Configure Container Scanning in `.gitlab-ci.yml` using the GitLab managed template. You can [add variable overrides](https://docs.gitlab.com/ee/user/application_security/container_scanning/#customizing-the-container-scanning-settings) to customize Container Scanning settings.')
end
+
+ def name
+ 'Container Scanning'
+ end
end
end
end
diff --git a/app/services/security/ci_configuration/dependency_scanning_create_service.rb b/app/services/security/ci_configuration/dependency_scanning_create_service.rb
index b11eccc680c..66dd76c4b5d 100644
--- a/app/services/security/ci_configuration/dependency_scanning_create_service.rb
+++ b/app/services/security/ci_configuration/dependency_scanning_create_service.rb
@@ -21,6 +21,10 @@ module Security
def description
_('Configure Dependency Scanning in `.gitlab-ci.yml` using the GitLab managed template. You can [add variable overrides](https://docs.gitlab.com/ee/user/application_security/dependency_scanning/#customizing-the-dependency-scanning-settings) to customize Dependency Scanning settings.')
end
+
+ def name
+ 'Dependency Scanning'
+ end
end
end
end
diff --git a/app/services/security/ci_configuration/sast_create_service.rb b/app/services/security/ci_configuration/sast_create_service.rb
index d78e22f1fe1..643cb7f89dc 100644
--- a/app/services/security/ci_configuration/sast_create_service.rb
+++ b/app/services/security/ci_configuration/sast_create_service.rb
@@ -20,13 +20,7 @@ module Security
end
def action
- existing_content = begin
- existing_gitlab_ci_content # this can fail on the very first commit
- rescue StandardError
- nil
- end
-
- Security::CiConfiguration::SastBuildAction.new(project.auto_devops_enabled?, params, existing_content, project.ci_config_path).generate
+ Security::CiConfiguration::SastBuildAction.new(project.auto_devops_enabled?, params, existing_gitlab_ci_content, project.ci_config_path).generate
end
def next_branch
@@ -40,6 +34,10 @@ module Security
def description
_('Configure SAST in `.gitlab-ci.yml` using the GitLab managed template. You can [add variable overrides](https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings) to customize SAST settings.')
end
+
+ def name
+ 'SAST'
+ end
end
end
end
diff --git a/app/services/security/ci_configuration/sast_iac_create_service.rb b/app/services/security/ci_configuration/sast_iac_create_service.rb
index fbc65484216..61bbebd77d0 100644
--- a/app/services/security/ci_configuration/sast_iac_create_service.rb
+++ b/app/services/security/ci_configuration/sast_iac_create_service.rb
@@ -21,6 +21,10 @@ module Security
def description
_('Configure SAST IaC in `.gitlab-ci.yml` using the GitLab managed template. You can [add variable overrides](https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings) to customize SAST IaC settings.')
end
+
+ def name
+ 'SAST IaC'
+ end
end
end
end
diff --git a/app/services/security/ci_configuration/secret_detection_create_service.rb b/app/services/security/ci_configuration/secret_detection_create_service.rb
index ca5138b6ed6..792fe4986e9 100644
--- a/app/services/security/ci_configuration/secret_detection_create_service.rb
+++ b/app/services/security/ci_configuration/secret_detection_create_service.rb
@@ -21,6 +21,10 @@ module Security
def description
_('Configure Secret Detection in `.gitlab-ci.yml` using the GitLab managed template. You can [add variable overrides](https://docs.gitlab.com/ee/user/application_security/secret_detection/#customizing-settings) to customize Secret Detection settings.')
end
+
+ def name
+ 'Secret Detection'
+ end
end
end
end
diff --git a/app/services/service_ping/submit_service.rb b/app/services/service_ping/submit_service.rb
index da2a51562f8..6d39174b6c7 100644
--- a/app/services/service_ping/submit_service.rb
+++ b/app/services/service_ping/submit_service.rb
@@ -42,20 +42,20 @@ module ServicePing
{
metadata: {
uuid: service_ping_payload[:uuid],
- metrics: metrics_collection_time(service_ping_payload)
+ metrics: metrics_collection_metadata(service_ping_payload)
}
}
end
- def metrics_collection_time(payload, parents = [])
+ def metrics_collection_metadata(payload, parents = [])
return [] unless payload.is_a?(Hash)
payload.flat_map do |key, metric_value|
key_path = parents.dup.append(key)
if metric_value.respond_to?(:duration)
- { name: key_path.join('.'), time_elapsed: metric_value.duration }
+ { name: key_path.join('.'), time_elapsed: metric_value.duration, error: metric_value.error }.compact
else
- metrics_collection_time(metric_value, key_path)
+ metrics_collection_metadata(metric_value, key_path)
end
end
end
diff --git a/app/services/service_response.rb b/app/services/service_response.rb
index 848f90e7f25..da4773ab9c7 100644
--- a/app/services/service_response.rb
+++ b/app/services/service_response.rb
@@ -26,22 +26,22 @@ class ServiceResponse
self.reason = reason
end
- def track_exception(as: StandardError, **extra_data)
- if error?
- e = as.new(message)
- Gitlab::ErrorTracking.track_exception(e, extra_data)
+ def log_and_raise_exception(as: StandardError, **extra_data)
+ error_tracking(as) do |ex|
+ Gitlab::ErrorTracking.log_and_raise_exception(ex, extra_data)
end
+ end
- self
+ def track_exception(as: StandardError, **extra_data)
+ error_tracking(as) do |ex|
+ Gitlab::ErrorTracking.track_exception(ex, extra_data)
+ end
end
def track_and_raise_exception(as: StandardError, **extra_data)
- if error?
- e = as.new(message)
- Gitlab::ErrorTracking.track_and_raise_exception(e, extra_data)
+ error_tracking(as) do |ex|
+ Gitlab::ErrorTracking.track_and_raise_exception(ex, extra_data)
end
-
- self
end
def [](key)
@@ -73,4 +73,13 @@ class ServiceResponse
private
attr_writer :status, :message, :http_status, :payload, :reason
+
+ def error_tracking(error_klass)
+ if error?
+ ex = error_klass.new(message)
+ yield ex
+ end
+
+ self
+ end
end
diff --git a/app/services/test_hooks/base_service.rb b/app/services/test_hooks/base_service.rb
index b41a9959c13..3f2949a53ba 100644
--- a/app/services/test_hooks/base_service.rb
+++ b/app/services/test_hooks/base_service.rb
@@ -16,9 +16,16 @@ module TestHooks
trigger_key = hook.class.triggers.key(trigger.to_sym)
return error('Testing not available for this hook') if trigger_key.nil? || data.blank?
+
return error(data[:error]) if data[:error].present?
hook.execute(data, trigger_key, force: true)
+ rescue ArgumentError => e
+ error(e.message)
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
end
end
end
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index 9ae31f8ac58..bfd1e55507c 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -220,7 +220,7 @@ class TodoService
create_todos(reviewers, attributes, project.namespace, project)
end
- def create_member_access_request(member)
+ def create_member_access_request_todos(member)
source = member.source
attributes = attributes_for_access_request_todos(source, member.user, Todo::MEMBER_ACCESS_REQUESTED)
@@ -433,7 +433,12 @@ class TodoService
note: note
}
- attributes[:group_id] = source.id unless source.instance_of? Project
+ if source.instance_of? Project
+ attributes[:project_id] = source.id
+ attributes[:group_id] = source.group.id if source.group.present?
+ else
+ attributes[:group_id] = source.id
+ end
attributes
end
diff --git a/app/services/users/block_service.rb b/app/services/users/block_service.rb
index 37921c477b4..0715e299e87 100644
--- a/app/services/users/block_service.rb
+++ b/app/services/users/block_service.rb
@@ -20,8 +20,14 @@ module Users
private
+ # overridden by EE module
def after_block_hook(user)
- # overridden by EE module
+ custom_attribute = {
+ user_id: user.id,
+ key: UserCustomAttribute::BLOCKED_BY,
+ value: "#{current_user.username}/#{current_user.id}+#{Time.current}"
+ }
+ UserCustomAttribute.upsert_custom_attributes([custom_attribute])
end
end
end
diff --git a/app/services/users/signup_service.rb b/app/services/users/signup_service.rb
index 1087ae76216..9eb1e75988c 100644
--- a/app/services/users/signup_service.rb
+++ b/app/services/users/signup_service.rb
@@ -12,9 +12,9 @@ module Users
inject_validators
if @user.save
- success
+ ServiceResponse.success
else
- error(@user.errors.full_messages.join('. '))
+ ServiceResponse.error(message: @user.errors.full_messages.join('. '))
end
end
diff --git a/app/services/users/unblock_service.rb b/app/services/users/unblock_service.rb
new file mode 100644
index 00000000000..1302395662f
--- /dev/null
+++ b/app/services/users/unblock_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Users
+ class UnblockService < BaseService
+ def initialize(current_user)
+ @current_user = current_user
+ end
+
+ def execute(user)
+ if user.activate
+ after_unblock_hook(user)
+ ServiceResponse.success(payload: { user: user })
+ else
+ ServiceResponse.error(message: user.errors.full_messages)
+ end
+ end
+
+ private
+
+ def after_unblock_hook(user)
+ custom_attribute = {
+ user_id: user.id,
+ key: UserCustomAttribute::UNBLOCKED_BY,
+ value: "#{current_user.username}/#{current_user.id}+#{Time.current}"
+ }
+ UserCustomAttribute.upsert_custom_attributes([custom_attribute])
+ end
+ end
+end
diff --git a/app/services/work_items/parent_links/create_service.rb b/app/services/work_items/parent_links/create_service.rb
index e7906f1fcdd..288ca152f93 100644
--- a/app/services/work_items/parent_links/create_service.rb
+++ b/app/services/work_items/parent_links/create_service.rb
@@ -46,7 +46,7 @@ module WorkItems
end
def target_issuable_type
- issuable.issue_type == 'issue' ? 'task' : issuable.issue_type
+ 'work item'
end
def issuables_not_found_message
diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb
index e74998ce4a8..1b47400d5e8 100644
--- a/app/uploaders/object_storage.rb
+++ b/app/uploaders/object_storage.rb
@@ -139,6 +139,7 @@ module ObjectStorage
hash[:TempPath] = workhorse_local_upload_path
end
+ hash[:UploadHashFunctions] = %w[sha1 sha256 sha512] if ::Gitlab::FIPS.enabled?
hash[:MaximumSize] = maximum_size if maximum_size.present?
end
end
diff --git a/app/validators/json_schemas/cyclonedx_report.json b/app/validators/json_schemas/cyclonedx_report.json
index 65c3c3c0cb9..7b24c05a039 100644
--- a/app/validators/json_schemas/cyclonedx_report.json
+++ b/app/validators/json_schemas/cyclonedx_report.json
@@ -3,7 +3,7 @@
"$id": "http://cyclonedx.org/schema/bom-1.4.schema.json",
"type": "object",
"title": "CycloneDX Software Bill of Materials Standard",
- "$comment" : "CycloneDX JSON schema is published under the terms of the Apache License 2.0.",
+ "$comment": "CycloneDX JSON schema is published under the terms of the Apache License 2.0.",
"required": [
"bomFormat",
"specVersion",
@@ -29,13 +29,17 @@
"type": "string",
"title": "CycloneDX Specification Version",
"description": "The version of the CycloneDX specification a BOM conforms to (starting at version 1.2).",
- "examples": ["1.4"]
+ "examples": [
+ "1.4"
+ ]
},
"serialNumber": {
"type": "string",
"title": "BOM Serial Number",
"description": "Every BOM generated SHOULD have a unique serial number, even if the contents of the BOM have not changed over time. If specified, the serial number MUST conform to RFC-4122. Use of serial numbers are RECOMMENDED.",
- "examples": ["urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79"],
+ "examples": [
+ "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79"
+ ],
"pattern": "^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"version": {
@@ -43,7 +47,9 @@
"title": "BOM Version",
"description": "Whenever an existing BOM is modified, either manually or through automated processes, the version of the BOM SHOULD be incremented by 1. When a system is presented with multiple BOMs with identical serial numbers, the system SHOULD use the most recent version of the BOM. The default version is '1'.",
"default": 1,
- "examples": [1]
+ "examples": [
+ 1
+ ]
},
"metadata": {
"$ref": "#/definitions/metadata",
@@ -53,7 +59,9 @@
"components": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/component"},
+ "items": {
+ "$ref": "#/definitions/component"
+ },
"uniqueItems": true,
"title": "Components",
"description": "A list of software and hardware components."
@@ -61,7 +69,9 @@
"services": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/service"},
+ "items": {
+ "$ref": "#/definitions/service"
+ },
"uniqueItems": true,
"title": "Services",
"description": "A list of services. This may include microservices, function-as-a-service, and other types of network or intra-process services."
@@ -69,14 +79,18 @@
"externalReferences": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/externalReference"},
+ "items": {
+ "$ref": "#/definitions/externalReference"
+ },
"title": "External References",
"description": "External references provide a way to document systems, sites, and information that may be relevant but which are not included with the BOM."
},
"dependencies": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/dependency"},
+ "items": {
+ "$ref": "#/definitions/dependency"
+ },
"uniqueItems": true,
"title": "Dependencies",
"description": "Provides the ability to document dependency relationships."
@@ -84,7 +98,9 @@
"compositions": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/compositions"},
+ "items": {
+ "$ref": "#/definitions/compositions"
+ },
"uniqueItems": true,
"title": "Compositions",
"description": "Compositions describe constituent parts (including components, services, and dependency relationships) and their completeness."
@@ -92,7 +108,9 @@
"vulnerabilities": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/vulnerability"},
+ "items": {
+ "$ref": "#/definitions/vulnerability"
+ },
"uniqueItems": true,
"title": "Vulnerabilities",
"description": "Vulnerabilities identified in components or services."
@@ -124,14 +142,18 @@
"title": "Creation Tools",
"description": "The tool(s) used in the creation of the BOM.",
"additionalItems": false,
- "items": {"$ref": "#/definitions/tool"}
+ "items": {
+ "$ref": "#/definitions/tool"
+ }
},
- "authors" :{
+ "authors": {
"type": "array",
"title": "Authors",
"description": "The person(s) who created the BOM. Authors are common in BOMs created through manual processes. BOMs created through automated means may not have authors.",
"additionalItems": false,
- "items": {"$ref": "#/definitions/organizationalContact"}
+ "items": {
+ "$ref": "#/definitions/organizationalContact"
+ }
},
"component": {
"title": "Component",
@@ -152,14 +174,18 @@
"type": "array",
"title": "BOM License(s)",
"additionalItems": false,
- "items": {"$ref": "#/definitions/licenseChoice"}
+ "items": {
+ "$ref": "#/definitions/licenseChoice"
+ }
},
"properties": {
"type": "array",
"title": "Properties",
"description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.",
"additionalItems": false,
- "items": {"$ref": "#/definitions/property"}
+ "items": {
+ "$ref": "#/definitions/property"
+ }
}
}
},
@@ -187,14 +213,18 @@
"hashes": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/hash"},
+ "items": {
+ "$ref": "#/definitions/hash"
+ },
"title": "Hashes",
"description": "The hashes of the tool (if applicable)."
},
"externalReferences": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/externalReference"},
+ "items": {
+ "$ref": "#/definitions/externalReference"
+ },
"title": "External References",
"description": "External references provide a way to document systems, sites, and information that may be relevant but which are not included with the BOM."
}
@@ -222,14 +252,18 @@
},
"title": "URL",
"description": "The URL of the organization. Multiple URLs are allowed.",
- "examples": ["https://example.com"]
+ "examples": [
+ "https://example.com"
+ ]
},
"contact": {
"type": "array",
"title": "Contact",
"description": "A contact at the organization. Multiple contacts are allowed.",
"additionalItems": false,
- "items": {"$ref": "#/definitions/organizationalContact"}
+ "items": {
+ "$ref": "#/definitions/organizationalContact"
+ }
}
}
},
@@ -243,20 +277,26 @@
"type": "string",
"title": "Name",
"description": "The name of a contact",
- "examples": ["Contact name"]
+ "examples": [
+ "Contact name"
+ ]
},
"email": {
"type": "string",
"format": "idn-email",
"title": "Email Address",
"description": "The email address of the contact.",
- "examples": ["firstname.lastname@example.com"]
+ "examples": [
+ "firstname.lastname@example.com"
+ ]
},
"phone": {
"type": "string",
"title": "Phone",
"description": "The phone number of the contact.",
- "examples": ["800-555-1212"]
+ "examples": [
+ "800-555-1212"
+ ]
}
}
},
@@ -283,13 +323,17 @@
],
"title": "Component Type",
"description": "Specifies the type of component. For software components, classify as application if no more specific appropriate classification is available or cannot be determined for the component. Types include:\n\n* __application__ = A software application. Refer to [https://en.wikipedia.org/wiki/Application_software](https://en.wikipedia.org/wiki/Application_software) for information about applications.\n* __framework__ = A software framework. Refer to [https://en.wikipedia.org/wiki/Software_framework](https://en.wikipedia.org/wiki/Software_framework) for information on how frameworks vary slightly from libraries.\n* __library__ = A software library. Refer to [https://en.wikipedia.org/wiki/Library_(computing)](https://en.wikipedia.org/wiki/Library_(computing))\n for information about libraries. All third-party and open source reusable components will likely be a library. If the library also has key features of a framework, then it should be classified as a framework. If not, or is unknown, then specifying library is RECOMMENDED.\n* __container__ = A packaging and/or runtime format, not specific to any particular technology, which isolates software inside the container from software outside of a container through virtualization technology. Refer to [https://en.wikipedia.org/wiki/OS-level_virtualization](https://en.wikipedia.org/wiki/OS-level_virtualization)\n* __operating-system__ = A software operating system without regard to deployment model (i.e. installed on physical hardware, virtual machine, image, etc) Refer to [https://en.wikipedia.org/wiki/Operating_system](https://en.wikipedia.org/wiki/Operating_system)\n* __device__ = A hardware device such as a processor, or chip-set. A hardware device containing firmware SHOULD include a component for the physical hardware itself, and another component of type 'firmware' or 'operating-system' (whichever is relevant), describing information about the software running on the device.\n* __firmware__ = A special type of software that provides low-level control over a devices hardware. Refer to [https://en.wikipedia.org/wiki/Firmware](https://en.wikipedia.org/wiki/Firmware)\n* __file__ = A computer file. Refer to [https://en.wikipedia.org/wiki/Computer_file](https://en.wikipedia.org/wiki/Computer_file) for information about files.",
- "examples": ["library"]
+ "examples": [
+ "library"
+ ]
},
"mime-type": {
"type": "string",
"title": "Mime-Type",
"description": "The optional mime-type of the component. When used on file components, the mime-type can provide additional context about the kind of file being represented such as an image, font, or executable. Some library or framework components may also have an associated mime-type.",
- "examples": ["image/jpeg"],
+ "examples": [
+ "image/jpeg"
+ ],
"pattern": "^[-+a-z0-9.]+/[-+a-z0-9.]+$"
},
"bom-ref": {
@@ -306,31 +350,41 @@
"type": "string",
"title": "Component Author",
"description": "The person(s) or organization(s) that authored the component",
- "examples": ["Acme Inc"]
+ "examples": [
+ "Acme Inc"
+ ]
},
"publisher": {
"type": "string",
"title": "Component Publisher",
"description": "The person(s) or organization(s) that published the component",
- "examples": ["Acme Inc"]
+ "examples": [
+ "Acme Inc"
+ ]
},
"group": {
"type": "string",
"title": "Component Group",
"description": "The grouping name or identifier. This will often be a shortened, single name of the company or project that produced the component, or the source package or domain name. Whitespace and special characters should be avoided. Examples include: apache, org.apache.commons, and apache.org.",
- "examples": ["com.acme"]
+ "examples": [
+ "com.acme"
+ ]
},
"name": {
"type": "string",
"title": "Component Name",
"description": "The name of the component. This will often be a shortened, single name of the component. Examples: commons-lang3 and jquery",
- "examples": ["tomcat-catalina"]
+ "examples": [
+ "tomcat-catalina"
+ ]
},
"version": {
"type": "string",
"title": "Component Version",
"description": "The component version. The version should ideally comply with semantic versioning but is not enforced.",
- "examples": ["9.0.14"]
+ "examples": [
+ "9.0.14"
+ ]
},
"description": {
"type": "string",
@@ -352,31 +406,41 @@
"type": "array",
"title": "Component Hashes",
"additionalItems": false,
- "items": {"$ref": "#/definitions/hash"}
+ "items": {
+ "$ref": "#/definitions/hash"
+ }
},
"licenses": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/licenseChoice"},
+ "items": {
+ "$ref": "#/definitions/licenseChoice"
+ },
"title": "Component License(s)"
},
"copyright": {
"type": "string",
"title": "Component Copyright",
"description": "A copyright notice informing users of the underlying claims to copyright ownership in a published work.",
- "examples": ["Acme Inc"]
+ "examples": [
+ "Acme Inc"
+ ]
},
"cpe": {
"type": "string",
"title": "Component Common Platform Enumeration (CPE)",
"description": "Specifies a well-formed CPE name that conforms to the CPE 2.2 or 2.3 specification. See [https://nvd.nist.gov/products/cpe](https://nvd.nist.gov/products/cpe)",
- "examples": ["cpe:2.3:a:acme:component_framework:-:*:*:*:*:*:*:*"]
+ "examples": [
+ "cpe:2.3:a:acme:component_framework:-:*:*:*:*:*:*:*"
+ ]
},
"purl": {
"type": "string",
"title": "Component Package URL (purl)",
"description": "Specifies the package-url (purl). The purl, if specified, MUST be valid and conform to the specification defined at: [https://github.com/package-url/purl-spec](https://github.com/package-url/purl-spec)",
- "examples": ["pkg:maven/com.acme/tomcat-catalina@9.0.14?packaging=jar"]
+ "examples": [
+ "pkg:maven/com.acme/tomcat-catalina@9.0.14?packaging=jar"
+ ]
},
"swid": {
"$ref": "#/definitions/swid",
@@ -399,35 +463,45 @@
"title": "Ancestors",
"description": "Describes zero or more components in which a component is derived from. This is commonly used to describe forks from existing projects where the forked version contains a ancestor node containing the original component it was forked from. For example, Component A is the original component. Component B is the component being used and documented in the BOM. However, Component B contains a pedigree node with a single ancestor documenting Component A - the original component from which Component B is derived from.",
"additionalItems": false,
- "items": {"$ref": "#/definitions/component"}
+ "items": {
+ "$ref": "#/definitions/component"
+ }
},
"descendants": {
"type": "array",
"title": "Descendants",
"description": "Descendants are the exact opposite of ancestors. This provides a way to document all forks (and their forks) of an original or root component.",
"additionalItems": false,
- "items": {"$ref": "#/definitions/component"}
+ "items": {
+ "$ref": "#/definitions/component"
+ }
},
"variants": {
"type": "array",
"title": "Variants",
"description": "Variants describe relations where the relationship between the components are not known. For example, if Component A contains nearly identical code to Component B. They are both related, but it is unclear if one is derived from the other, or if they share a common ancestor.",
"additionalItems": false,
- "items": {"$ref": "#/definitions/component"}
+ "items": {
+ "$ref": "#/definitions/component"
+ }
},
"commits": {
"type": "array",
"title": "Commits",
"description": "A list of zero or more commits which provide a trail describing how the component deviates from an ancestor, descendant, or variant.",
"additionalItems": false,
- "items": {"$ref": "#/definitions/commit"}
+ "items": {
+ "$ref": "#/definitions/commit"
+ }
},
"patches": {
"type": "array",
"title": "Patches",
"description": ">A list of zero or more patches describing how the component deviates from an ancestor, descendant, or variant. Patches may be complimentary to commits or may be used in place of commits.",
"additionalItems": false,
- "items": {"$ref": "#/definitions/patch"}
+ "items": {
+ "$ref": "#/definitions/patch"
+ }
},
"notes": {
"type": "string",
@@ -439,14 +513,18 @@
"externalReferences": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/externalReference"},
+ "items": {
+ "$ref": "#/definitions/externalReference"
+ },
"title": "External References",
"description": "External references provide a way to document systems, sites, and information that may be relevant but which are not included with the BOM."
},
"components": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/component"},
+ "items": {
+ "$ref": "#/definitions/component"
+ },
"uniqueItems": true,
"title": "Components",
"description": "A list of software and hardware components included in the parent component. This is not a dependency tree. It provides a way to specify a hierarchical representation of component assemblies, similar to system &#8594; subsystem &#8594; parts assembly in physical supply chains."
@@ -466,7 +544,9 @@
"title": "Properties",
"description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.",
"additionalItems": false,
- "items": {"$ref": "#/definitions/property"}
+ "items": {
+ "$ref": "#/definitions/property"
+ }
},
"signature": {
"$ref": "#/definitions/signature",
@@ -594,7 +674,9 @@
"hash-content": {
"type": "string",
"title": "Hash Content (value)",
- "examples": ["3942447fac867ae5cdb3229b658f4d48"],
+ "examples": [
+ "3942447fac867ae5cdb3229b658f4d48"
+ ],
"pattern": "^([a-fA-F0-9]{32}|[a-fA-F0-9]{40}|[a-fA-F0-9]{64}|[a-fA-F0-9]{96}|[a-fA-F0-9]{128})$"
},
"license": {
@@ -602,25 +684,565 @@
"title": "License Object",
"oneOf": [
{
- "required": ["id"]
+ "required": [
+ "id"
+ ]
},
{
- "required": ["name"]
+ "required": [
+ "name"
+ ]
}
],
"additionalProperties": false,
"properties": {
"id": {
- "$ref": "spdx.schema.json",
"title": "License ID (SPDX)",
"description": "A valid SPDX license ID",
- "examples": ["Apache-2.0"]
+ "examples": [
+ "Apache-2.0"
+ ],
+ "type": "string",
+ "enum": [
+ "CC-BY-NC-ND-2.0",
+ "SGI-B-2.0",
+ "LPPL-1.3c",
+ "NIST-PD-fallback",
+ "libtiff",
+ "XSkat",
+ "PDDL-1.0",
+ "KiCad-libraries-exception",
+ "CC-BY-NC-SA-1.0",
+ "GFDL-1.1-no-invariants-only",
+ "Xerox",
+ "LPPL-1.1",
+ "VOSTROM",
+ "UCL-1.0",
+ "ADSL",
+ "OSL-2.0",
+ "AAL",
+ "FDK-AAC",
+ "W3C-20150513",
+ "AFL-1.1",
+ "W3C",
+ "Sleepycat",
+ "CECILL-1.1",
+ "mpich2",
+ "SISSL",
+ "NLOD-1.0",
+ "ANTLR-PD",
+ "GPL-3.0-only",
+ "gnuplot",
+ "NLOD-2.0",
+ "BSD-3-Clause-Open-MPI",
+ "LiLiQ-P-1.1",
+ "BSD-3-Clause-Clear",
+ "FSFUL",
+ "CC-BY-NC-SA-2.0-UK",
+ "CERN-OHL-S-2.0",
+ "Spencer-94",
+ "CERN-OHL-1.2",
+ "GFDL-1.1-or-later",
+ "AGPL-1.0-or-later",
+ "Wsuipa",
+ "AML",
+ "BSD-2-Clause",
+ "DSDP",
+ "CC-BY-2.5",
+ "MIT-CMU",
+ "Beerware",
+ "Sendmail",
+ "TU-Berlin-1.0",
+ "CNRI-Jython",
+ "mplus",
+ "CPOL-1.02",
+ "BSD-3-Clause-No-Nuclear-License-2014",
+ "ISC",
+ "CC-BY-SA-4.0",
+ "Eurosym",
+ "LGPL-3.0-only",
+ "OLDAP-1.3",
+ "GFDL-1.1-invariants-or-later",
+ "Glulxe",
+ "SimPL-2.0",
+ "CDLA-Permissive-2.0",
+ "GPL-2.0-with-font-exception",
+ "OGL-UK-2.0",
+ "CC-BY-SA-3.0-DE",
+ "CC-BY-ND-1.0",
+ "GFDL-1.1",
+ "CC-BY-4.0",
+ "OpenSSL",
+ "TU-Berlin-2.0",
+ "DOC",
+ "GFDL-1.2-no-invariants-or-later",
+ "QPL-1.0",
+ "OLDAP-2.8",
+ "OML",
+ "OLDAP-2.7",
+ "NIST-PD",
+ "Bitstream-Vera",
+ "GFDL-1.2-or-later",
+ "OFL-1.1-RFN",
+ "Bahyph",
+ "Barr",
+ "COIL-1.0",
+ "GFDL-1.3",
+ "CECILL-B",
+ "JPNIC",
+ "Zed",
+ "ICU",
+ "CC-BY-NC-SA-2.5",
+ "CC-BY-ND-3.0-DE",
+ "bzip2-1.0.5",
+ "SPL-1.0",
+ "YPL-1.0",
+ "OSET-PL-2.1",
+ "Noweb",
+ "RPSL-1.0",
+ "BSD-3-Clause-LBNL",
+ "CDLA-Sharing-1.0",
+ "CECILL-1.0",
+ "AMPAS",
+ "APAFML",
+ "CC-BY-ND-3.0",
+ "D-FSL-1.0",
+ "CC-BY-NC-3.0",
+ "libpng-2.0",
+ "PolyForm-Noncommercial-1.0.0",
+ "dvipdfm",
+ "GFDL-1.3-or-later",
+ "OGTSL",
+ "NPL-1.1",
+ "GPL-3.0",
+ "CERN-OHL-P-2.0",
+ "BlueOak-1.0.0",
+ "AGPL-3.0-or-later",
+ "blessing",
+ "ImageMagick",
+ "APSL-2.0",
+ "MIT-advertising",
+ "curl",
+ "CC0-1.0",
+ "Zimbra-1.4",
+ "SSPL-1.0",
+ "psutils",
+ "CC-BY-SA-2.0-UK",
+ "PSF-2.0",
+ "Net-SNMP",
+ "NAIST-2003",
+ "GFDL-1.2-invariants-or-later",
+ "SGI-B-1.0",
+ "NBPL-1.0",
+ "GFDL-1.2-invariants-only",
+ "W3C-19980720",
+ "OFL-1.0-no-RFN",
+ "NetCDF",
+ "TMate",
+ "NOSL",
+ "CNRI-Python-GPL-Compatible",
+ "BSD-1-Clause",
+ "CC-BY-NC-SA-3.0-DE",
+ "BSD-3-Clause-Modification",
+ "GLWTPL",
+ "GFDL-1.3-only",
+ "OLDAP-2.2",
+ "CC-BY-ND-4.0",
+ "CC-BY-NC-ND-3.0-DE",
+ "EUPL-1.0",
+ "Linux-OpenIB",
+ "LGPL-2.0-or-later",
+ "OSL-1.1",
+ "Spencer-86",
+ "LGPL-2.0",
+ "CC-PDDC",
+ "CC-BY-NC-ND-3.0",
+ "CDL-1.0",
+ "Elastic-2.0",
+ "CC-BY-2.0",
+ "BSD-3-Clause-No-Military-License",
+ "IJG",
+ "LPPL-1.3a",
+ "SAX-PD",
+ "BitTorrent-1.0",
+ "OLDAP-2.0",
+ "Giftware",
+ "C-UDA-1.0",
+ "LGPL-2.0+",
+ "Rdisc",
+ "GPL-2.0-with-classpath-exception",
+ "CC-BY-3.0-US",
+ "CDDL-1.0",
+ "Xnet",
+ "CPL-1.0",
+ "LGPL-3.0-or-later",
+ "NASA-1.3",
+ "BUSL-1.1",
+ "etalab-2.0",
+ "MIT-open-group",
+ "OLDAP-1.4",
+ "GFDL-1.1-invariants-only",
+ "RPL-1.1",
+ "CC-BY-NC-ND-2.5",
+ "FSFULLR",
+ "Saxpath",
+ "NTP-0",
+ "SISSL-1.2",
+ "GPL-3.0-or-later",
+ "Apache-1.1",
+ "CC-BY-SA-2.1-JP",
+ "AGPL-3.0-only",
+ "GPL-2.0-with-autoconf-exception",
+ "Artistic-2.0",
+ "App-s2p",
+ "Unicode-DFS-2015",
+ "diffmark",
+ "SNIA",
+ "CC-BY-SA-2.5",
+ "Linux-man-pages-copyleft",
+ "HPND-sell-variant",
+ "ZPL-2.1",
+ "BSD-4-Clause-UC",
+ "LAL-1.2",
+ "AGPL-1.0-only",
+ "MIT-enna",
+ "Condor-1.1",
+ "Naumen",
+ "GFDL-1.3-no-invariants-or-later",
+ "RPL-1.5",
+ "PolyForm-Small-Business-1.0.0",
+ "EFL-1.0",
+ "MirOS",
+ "CC-BY-2.5-AU",
+ "Afmparse",
+ "MPL-2.0-no-copyleft-exception",
+ "LiLiQ-Rplus-1.1",
+ "AFL-1.2",
+ "OSL-1.0",
+ "GPL-1.0-only",
+ "APSL-1.0",
+ "OGL-Canada-2.0",
+ "CPAL-1.0",
+ "Latex2e",
+ "Zend-2.0",
+ "Unlicense",
+ "xpp",
+ "CC-BY-NC-1.0",
+ "GPL-3.0-with-autoconf-exception",
+ "CC-BY-NC-SA-3.0",
+ "TCP-wrappers",
+ "SCEA",
+ "SSH-short",
+ "CC-BY-3.0-NL",
+ "SchemeReport",
+ "CC-BY-3.0",
+ "MPL-2.0",
+ "Unicode-TOU",
+ "CC-BY-NC-ND-1.0",
+ "Entessa",
+ "BSD-3-Clause-No-Nuclear-License",
+ "SWL",
+ "GFDL-1.2-no-invariants-only",
+ "Parity-7.0.0",
+ "OLDAP-2.2.1",
+ "SGI-B-1.1",
+ "FTL",
+ "OLDAP-2.4",
+ "CC-BY-NC-4.0",
+ "bzip2-1.0.6",
+ "copyleft-next-0.3.0",
+ "MakeIndex",
+ "NRL",
+ "GFDL-1.3-invariants-or-later",
+ "CC-BY-NC-2.0",
+ "SugarCRM-1.1.3",
+ "AFL-2.1",
+ "GPL-2.0-only",
+ "GFDL-1.3-invariants-only",
+ "TORQUE-1.1",
+ "Ruby",
+ "X11",
+ "Borceux",
+ "Libpng",
+ "X11-distribute-modifications-variant",
+ "Frameworx-1.0",
+ "NCGL-UK-2.0",
+ "CECILL-2.1",
+ "CC-BY-3.0-AT",
+ "CNRI-Python",
+ "NCSA",
+ "gSOAP-1.3b",
+ "EUPL-1.1",
+ "AMDPLPA",
+ "Imlib2",
+ "CDDL-1.1",
+ "WTFPL",
+ "LPL-1.0",
+ "EPL-1.0",
+ "BSD-3-Clause-Attribution",
+ "OSL-3.0",
+ "RHeCos-1.1",
+ "PHP-3.0",
+ "BSD-Protection",
+ "CC-BY-NC-3.0-DE",
+ "APL-1.0",
+ "EUDatagrid",
+ "GPL-1.0",
+ "SHL-0.5",
+ "CC-BY-SA-2.0",
+ "CC-BY-SA-3.0-AT",
+ "CC-BY-NC-SA-3.0-IGO",
+ "Adobe-2006",
+ "Newsletr",
+ "Nunit",
+ "Multics",
+ "OGL-UK-1.0",
+ "Vim",
+ "eCos-2.0",
+ "Zimbra-1.3",
+ "eGenix",
+ "IBM-pibs",
+ "BitTorrent-1.1",
+ "OFL-1.1-no-RFN",
+ "psfrag",
+ "CC-BY-ND-2.0",
+ "SHL-0.51",
+ "FreeBSD-DOC",
+ "Python-2.0",
+ "Mup",
+ "BSD-4-Clause-Shortened",
+ "CC-BY-NC-SA-4.0",
+ "HPND",
+ "OLDAP-2.6",
+ "MPL-1.1",
+ "GPL-2.0-with-GCC-exception",
+ "HaskellReport",
+ "ECL-1.0",
+ "LGPL-2.1-or-later",
+ "OFL-1.0",
+ "APSL-1.1",
+ "MITNFA",
+ "CECILL-2.0",
+ "Crossword",
+ "Aladdin",
+ "Baekmuk",
+ "XFree86-1.1",
+ "GPL-1.0-or-later",
+ "CERN-OHL-W-2.0",
+ "CC-BY-SA-1.0",
+ "NTP",
+ "PHP-3.01",
+ "OCLC-2.0",
+ "CC-BY-3.0-DE",
+ "CC-BY-NC-2.5",
+ "Zlib",
+ "CATOSL-1.1",
+ "LGPL-3.0+",
+ "CAL-1.0",
+ "NPL-1.0",
+ "SMLNJ",
+ "GPL-2.0+",
+ "OLDAP-2.5",
+ "JasPer-2.0",
+ "GPL-2.0-or-later",
+ "BSD-2-Clause-Patent",
+ "MS-RL",
+ "CUA-OPL-1.0",
+ "IPA",
+ "NLPL",
+ "O-UDA-1.0",
+ "MIT-Modern-Variant",
+ "OLDAP-1.2",
+ "BSD-2-Clause-FreeBSD",
+ "Info-ZIP",
+ "CC-BY-NC-SA-2.0-FR",
+ "0BSD",
+ "Unicode-DFS-2016",
+ "OFL-1.0-RFN",
+ "Intel",
+ "AFL-2.0",
+ "GL2PS",
+ "TAPR-OHL-1.0",
+ "Apache-1.0",
+ "MTLL",
+ "Motosoto",
+ "RSA-MD",
+ "Community-Spec-1.0",
+ "ODC-By-1.0",
+ "zlib-acknowledgement",
+ "DL-DE-BY-2.0",
+ "VSL-1.0",
+ "LiLiQ-R-1.1",
+ "OPL-1.0",
+ "GPL-3.0+",
+ "MulanPSL-2.0",
+ "APSL-1.2",
+ "OGDL-Taiwan-1.0",
+ "RSCPL",
+ "OGC-1.0",
+ "EFL-2.0",
+ "CAL-1.0-Combined-Work-Exception",
+ "MS-PL",
+ "Plexus",
+ "Sendmail-8.23",
+ "Cube",
+ "JSON",
+ "EUPL-1.2",
+ "Adobe-Glyph",
+ "FreeImage",
+ "Watcom-1.0",
+ "Jam",
+ "Hippocratic-2.1",
+ "OLDAP-2.0.1",
+ "CC-BY-NC-SA-2.0",
+ "Nokia",
+ "OCCT-PL",
+ "ErlPL-1.1",
+ "TOSL",
+ "OSL-2.1",
+ "ClArtistic",
+ "xinetd",
+ "GPL-3.0-with-GCC-exception",
+ "ODbL-1.0",
+ "MIT",
+ "LGPL-2.1+",
+ "LGPL-2.1-only",
+ "CrystalStacker",
+ "ECL-2.0",
+ "LPPL-1.0",
+ "iMatix",
+ "CC-BY-NC-ND-3.0-IGO",
+ "BSD-Source-Code",
+ "Parity-6.0.0",
+ "TCL",
+ "Arphic-1999",
+ "CC-BY-SA-3.0",
+ "Caldera",
+ "AGPL-1.0",
+ "IPL-1.0",
+ "LAL-1.3",
+ "EPICS",
+ "NGPL",
+ "DRL-1.0",
+ "BSD-2-Clause-NetBSD",
+ "ZPL-1.1",
+ "GD",
+ "LPPL-1.2",
+ "Dotseqn",
+ "Spencer-99",
+ "OLDAP-2.3",
+ "YPL-1.1",
+ "Fair",
+ "Qhull",
+ "GFDL-1.1-no-invariants-or-later",
+ "CECILL-C",
+ "MulanPSL-1.0",
+ "OLDAP-1.1",
+ "OLDAP-2.1",
+ "LPL-1.02",
+ "UPL-1.0",
+ "Abstyles",
+ "ZPL-2.0",
+ "MIT-0",
+ "LGPL-2.0-only",
+ "GFDL-1.3-no-invariants-only",
+ "AGPL-3.0",
+ "EPL-2.0",
+ "AFL-3.0",
+ "CDLA-Permissive-1.0",
+ "Artistic-1.0",
+ "CC-BY-NC-ND-4.0",
+ "HTMLTIDY",
+ "Glide",
+ "FSFAP",
+ "LGPLLR",
+ "OGL-UK-3.0",
+ "GFDL-1.2",
+ "SSH-OpenSSH",
+ "GFDL-1.1-only",
+ "MIT-feh",
+ "MPL-1.0",
+ "PostgreSQL",
+ "OLDAP-2.2.2",
+ "SMPPL",
+ "OFL-1.1",
+ "Leptonica",
+ "CERN-OHL-1.1",
+ "BSD-3-Clause-No-Nuclear-Warranty",
+ "CC-BY-ND-2.5",
+ "CC-BY-1.0",
+ "GFDL-1.2-only",
+ "OPUBL-1.0",
+ "libselinux-1.0",
+ "BSD-3-Clause",
+ "ANTLR-PD-fallback",
+ "copyleft-next-0.3.1",
+ "GPL-1.0+",
+ "wxWindows",
+ "LGPL-3.0",
+ "LGPL-2.1",
+ "StandardML-NJ",
+ "BSD-4-Clause",
+ "GPL-2.0-with-bison-exception",
+ "Apache-2.0",
+ "Artistic-1.0-cl8",
+ "GPL-2.0",
+ "Intel-ACPI",
+ "BSL-1.0",
+ "Artistic-1.0-Perl",
+ "BSD-2-Clause-Views",
+ "Interbase-1.0",
+ "NPOSL-3.0",
+ "FLTK-exception",
+ "Bootloader-exception",
+ "WxWindows-exception-3.1",
+ "Linux-syscall-note",
+ "Qt-LGPL-exception-1.1",
+ "LLVM-exception",
+ "PS-or-PDF-font-exception-20170817",
+ "GCC-exception-3.1",
+ "Autoconf-exception-3.0",
+ "LGPL-3.0-linking-exception",
+ "GCC-exception-2.0",
+ "Bison-exception-2.2",
+ "openvpn-openssl-exception",
+ "Libtool-exception",
+ "Autoconf-exception-2.0",
+ "GPL-3.0-linking-source-exception",
+ "GPL-CC-1.0",
+ "OCaml-LGPL-linking-exception",
+ "Universal-FOSS-exception-1.0",
+ "i2p-gpl-java-exception",
+ "CLISP-exception-2.0",
+ "OCCT-exception-1.0",
+ "Qwt-exception-1.0",
+ "gnu-javamail-exception",
+ "u-boot-exception-2.0",
+ "freertos-exception-2.0",
+ "Qt-GPL-exception-1.0",
+ "OpenJDK-assembly-exception-1.0",
+ "SHL-2.1",
+ "mif-exception",
+ "Fawkes-Runtime-exception",
+ "Swift-exception",
+ "GPL-3.0-linking-exception",
+ "SHL-2.0",
+ "Classpath-exception-2.0",
+ "LZMA-exception",
+ "Font-exception-2.0",
+ "Nokia-Qt-exception-1.1",
+ "DigiRule-FOSS-exception",
+ "eCos-exception-2.0",
+ "389-exception"
+ ]
},
"name": {
"type": "string",
"title": "License Name",
"description": "If SPDX does not define the license used, this field may be used to provide the license name",
- "examples": ["Acme Software License"]
+ "examples": [
+ "Acme Software License"
+ ]
},
"text": {
"title": "License text",
@@ -631,7 +1253,9 @@
"type": "string",
"title": "License URL",
"description": "The URL to the license file. If specified, a 'license' externalReference should also be specified for completeness",
- "examples": ["https://www.apache.org/licenses/LICENSE-2.0.txt"],
+ "examples": [
+ "https://www.apache.org/licenses/LICENSE-2.0.txt"
+ ],
"format": "iri-reference"
}
}
@@ -653,12 +1277,16 @@
]
}
},
- "oneOf":[
+ "oneOf": [
{
- "required": ["license"]
+ "required": [
+ "license"
+ ]
},
{
- "required": ["expression"]
+ "required": [
+ "expression"
+ ]
}
]
},
@@ -724,7 +1352,9 @@
"resolves": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/issue"},
+ "items": {
+ "$ref": "#/definitions/issue"
+ },
"title": "Resolves",
"description": "A collection of issues the patch resolves"
}
@@ -810,7 +1440,9 @@
},
"title": "References",
"description": "A collection of URL's for reference. Multiple URLs are allowed.",
- "examples": ["https://example.com"]
+ "examples": [
+ "https://example.com"
+ ]
}
}
},
@@ -886,7 +1518,9 @@
"hashes": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/hash"},
+ "items": {
+ "$ref": "#/definitions/hash"
+ },
"title": "Hashes",
"description": "The hashes of the external reference (if applicable)."
}
@@ -940,19 +1574,25 @@
"type": "string",
"title": "Service Group",
"description": "The grouping name, namespace, or identifier. This will often be a shortened, single name of the company or project that produced the service or domain name. Whitespace and special characters should be avoided.",
- "examples": ["com.acme"]
+ "examples": [
+ "com.acme"
+ ]
},
"name": {
"type": "string",
"title": "Service Name",
"description": "The name of the service. This will often be a shortened, single name of the service.",
- "examples": ["ticker-service"]
+ "examples": [
+ "ticker-service"
+ ]
},
"version": {
"type": "string",
"title": "Service Version",
"description": "The service version.",
- "examples": ["1.0.0"]
+ "examples": [
+ "1.0.0"
+ ]
},
"description": {
"type": "string",
@@ -967,7 +1607,9 @@
},
"title": "Endpoints",
"description": "The endpoint URIs of the service. Multiple endpoints are allowed.",
- "examples": ["https://example.com/api/v1/ticker"]
+ "examples": [
+ "https://example.com/api/v1/ticker"
+ ]
},
"authenticated": {
"type": "boolean",
@@ -982,27 +1624,35 @@
"data": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/dataClassification"},
+ "items": {
+ "$ref": "#/definitions/dataClassification"
+ },
"title": "Data Classification",
"description": "Specifies the data classification."
},
"licenses": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/licenseChoice"},
+ "items": {
+ "$ref": "#/definitions/licenseChoice"
+ },
"title": "Component License(s)"
},
"externalReferences": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/externalReference"},
+ "items": {
+ "$ref": "#/definitions/externalReference"
+ },
"title": "External References",
"description": "External references provide a way to document systems, sites, and information that may be relevant but which are not included with the BOM."
},
"services": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/service"},
+ "items": {
+ "$ref": "#/definitions/service"
+ },
"uniqueItems": true,
"title": "Services",
"description": "A list of services included or deployed behind the parent service. This is not a dependency tree. It provides a way to specify a hierarchical representation of service assemblies."
@@ -1017,7 +1667,9 @@
"title": "Properties",
"description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.",
"additionalItems": false,
- "items": {"$ref": "#/definitions/property"}
+ "items": {
+ "$ref": "#/definitions/property"
+ }
},
"signature": {
"$ref": "#/definitions/signature",
@@ -1058,7 +1710,6 @@
"title": "Data flow direction",
"description": "Specifies the flow direction of the data. Direction is relative to the service. Inbound flow states that data enters the service. Outbound flow states that data leaves the service. Bi-directional states that data flows both ways, and unknown states that the direction is not known."
},
-
"copyright": {
"type": "object",
"title": "Copyright",
@@ -1073,7 +1724,6 @@
}
}
},
-
"componentEvidence": {
"type": "object",
"title": "Evidence",
@@ -1083,13 +1733,17 @@
"licenses": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/licenseChoice"},
+ "items": {
+ "$ref": "#/definitions/licenseChoice"
+ },
"title": "Component License(s)"
},
"copyright": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/copyright"},
+ "items": {
+ "$ref": "#/definitions/copyright"
+ },
"title": "Copyright"
}
}
@@ -1258,14 +1912,18 @@
"resolves": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/issue"},
+ "items": {
+ "$ref": "#/definitions/issue"
+ },
"title": "Resolves",
"description": "A collection of issues that have been resolved."
},
"notes": {
"type": "array",
"additionalItems": false,
- "items": {"$ref": "#/definitions/note"},
+ "items": {
+ "$ref": "#/definitions/note"
+ },
"title": "Notes",
"description": "Zero or more release notes containing the locale and content. Multiple note objects may be specified to support release notes in a wide variety of languages."
},
@@ -1274,7 +1932,9 @@
"title": "Properties",
"description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.",
"additionalItems": false,
- "items": {"$ref": "#/definitions/property"}
+ "items": {
+ "$ref": "#/definitions/property"
+ }
}
}
},
@@ -1282,7 +1942,9 @@
"type": "object",
"title": "Advisory",
"description": "Title and location where advisory information can be obtained. An advisory is a notification of a threat to a component, service, or system.",
- "required": ["url"],
+ "required": [
+ "url"
+ ],
"additionalProperties": false,
"properties": {
"title": {
@@ -1488,7 +2150,9 @@
"type": "array",
"title": "CWEs",
"description": "List of Common Weaknesses Enumerations (CWEs) codes that describes this vulnerability. For example 399 (of https://cwe.mitre.org/data/definitions/399.html)",
- "examples": [399],
+ "examples": [
+ 399
+ ],
"additionalItems": false,
"items": {
"$ref": "#/definitions/cwe"
@@ -1567,7 +2231,9 @@
"title": "Creation Tools",
"description": "The tool(s) used to identify, confirm, or score the vulnerability.",
"additionalItems": false,
- "items": {"$ref": "#/definitions/tool"}
+ "items": {
+ "$ref": "#/definitions/tool"
+ }
},
"analysis": {
"type": "object",
@@ -1627,10 +2293,14 @@
"items": {
"oneOf": [
{
- "required": ["version"]
+ "required": [
+ "version"
+ ]
},
{
- "required": ["range"]
+ "required": [
+ "range"
+ ]
}
],
"additionalProperties": false,
@@ -1689,9 +2359,256 @@
"maxLength": 1024
},
"signature": {
- "$ref": "jsf-0.82.schema.json#/definitions/signature",
"title": "Signature",
- "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)."
+ "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html).",
+ "type": "object",
+ "oneOf": [
+ {
+ "additionalProperties": false,
+ "properties": {
+ "signers": {
+ "type": "array",
+ "title": "Signature",
+ "description": "Unique top level property for Multiple Signatures. (multisignature)",
+ "additionalItems": false,
+ "items": {
+ "$ref": "#/definitions/signer"
+ }
+ }
+ }
+ },
+ {
+ "additionalProperties": false,
+ "properties": {
+ "chain": {
+ "type": "array",
+ "title": "Signature",
+ "description": "Unique top level property for Signature Chains. (signaturechain)",
+ "additionalItems": false,
+ "items": {
+ "$ref": "#/definitions/signer"
+ }
+ }
+ }
+ },
+ {
+ "title": "Signature",
+ "description": "Unique top level property for simple signatures. (signaturecore)",
+ "$ref": "#/definitions/signer"
+ }
+ ]
+ },
+ "signer": {
+ "type": "object",
+ "title": "Signature",
+ "required": [
+ "algorithm",
+ "value"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "algorithm": {
+ "oneOf": [
+ {
+ "type": "string",
+ "title": "Algorithm",
+ "description": "Signature algorithm. The currently recognized JWA [RFC7518] and RFC8037 [RFC8037] asymmetric key algorithms. Note: Unlike RFC8037 [RFC8037] JSF requires explicit Ed* algorithm names instead of \"EdDSA\".",
+ "enum": [
+ "RS256",
+ "RS384",
+ "RS512",
+ "PS256",
+ "PS384",
+ "PS512",
+ "ES256",
+ "ES384",
+ "ES512",
+ "Ed25519",
+ "Ed448",
+ "HS256",
+ "HS384",
+ "HS512"
+ ]
+ },
+ {
+ "type": "string",
+ "title": "Algorithm",
+ "description": "Signature algorithm. Note: If proprietary signature algorithms are added, they must be expressed as URIs.",
+ "format": "uri"
+ }
+ ]
+ },
+ "keyId": {
+ "type": "string",
+ "title": "Key ID",
+ "description": "Optional. Application specific string identifying the signature key."
+ },
+ "publicKey": {
+ "title": "Public key",
+ "description": "Optional. Public key object.",
+ "$ref": "#/definitions/publicKey"
+ },
+ "certificatePath": {
+ "type": "array",
+ "title": "Certificate path",
+ "description": "Optional. Sorted array of X.509 [RFC5280] certificates, where the first element must contain the signature certificate. The certificate path must be contiguous but is not required to be complete.",
+ "additionalItems": false,
+ "items": {
+ "type": "string"
+ }
+ },
+ "excludes": {
+ "type": "array",
+ "title": "Excludes",
+ "description": "Optional. Array holding the names of one or more application level properties that must be excluded from the signature process. Note that the \"excludes\" property itself, must also be excluded from the signature process. Since both the \"excludes\" property and the associated data it points to are unsigned, a conforming JSF implementation must provide options for specifying which properties to accept.",
+ "additionalItems": false,
+ "items": {
+ "type": "string"
+ }
+ },
+ "value": {
+ "type": "string",
+ "title": "Signature",
+ "description": "The signature data. Note that the binary representation must follow the JWA [RFC7518] specifications."
+ }
+ }
+ },
+ "keyType": {
+ "type": "string",
+ "title": "Key type",
+ "description": "Key type indicator.",
+ "enum": [
+ "EC",
+ "OKP",
+ "RSA"
+ ]
+ },
+ "publicKey": {
+ "title": "Public key",
+ "description": "Optional. Public key object.",
+ "type": "object",
+ "required": [
+ "kty"
+ ],
+ "additionalProperties": true,
+ "properties": {
+ "kty": {
+ "$ref": "#/definitions/keyType"
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "kty": {
+ "const": "EC"
+ }
+ }
+ },
+ "then": {
+ "required": [
+ "kty",
+ "crv",
+ "x",
+ "y"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "kty": {
+ "$ref": "#/definitions/keyType"
+ },
+ "crv": {
+ "type": "string",
+ "title": "Curve name",
+ "description": "EC curve name.",
+ "enum": [
+ "P-256",
+ "P-384",
+ "P-521"
+ ]
+ },
+ "x": {
+ "type": "string",
+ "title": "Coordinate",
+ "description": "EC curve point X. The length of this field must be the full size of a coordinate for the curve specified in the \"crv\" parameter. For example, if the value of \"crv\" is \"P-521\", the decoded argument must be 66 bytes."
+ },
+ "y": {
+ "type": "string",
+ "title": "Coordinate",
+ "description": "EC curve point Y. The length of this field must be the full size of a coordinate for the curve specified in the \"crv\" parameter. For example, if the value of \"crv\" is \"P-256\", the decoded argument must be 32 bytes."
+ }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "kty": {
+ "const": "OKP"
+ }
+ }
+ },
+ "then": {
+ "required": [
+ "kty",
+ "crv",
+ "x"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "kty": {
+ "$ref": "#/definitions/keyType"
+ },
+ "crv": {
+ "type": "string",
+ "title": "Curve name",
+ "description": "EdDSA curve name.",
+ "enum": [
+ "Ed25519",
+ "Ed448"
+ ]
+ },
+ "x": {
+ "type": "string",
+ "title": "Coordinate",
+ "description": "EdDSA curve point X. The length of this field must be the full size of a coordinate for the curve specified in the \"crv\" parameter. For example, if the value of \"crv\" is \"Ed25519\", the decoded argument must be 32 bytes."
+ }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "kty": {
+ "const": "RSA"
+ }
+ }
+ },
+ "then": {
+ "required": [
+ "kty",
+ "n",
+ "e"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "kty": {
+ "$ref": "#/definitions/keyType"
+ },
+ "n": {
+ "type": "string",
+ "title": "Modulus",
+ "description": "RSA modulus."
+ },
+ "e": {
+ "type": "string",
+ "title": "Exponent",
+ "description": "RSA exponent."
+ }
+ }
+ }
+ }
+ ]
}
}
-}
+} \ No newline at end of file
diff --git a/app/views/abuse_reports/new.html.haml b/app/views/abuse_reports/new.html.haml
index d5dfddef837..393021ed93c 100644
--- a/app/views/abuse_reports/new.html.haml
+++ b/app/views/abuse_reports/new.html.haml
@@ -1,28 +1,36 @@
- page_title _("Report abuse to administrator")
-%h1.page-title.gl-font-size-h-display
- = _("Report abuse to administrator")
-%p
- = _("Use this form to report to the administrator users who create spam issues, comments or behave inappropriately.")
-%p
- = _("A member of the abuse team will review your report as soon as possible.")
-%hr
-= gitlab_ui_form_for @abuse_report, html: { class: 'js-quick-submit js-requires-input'} do |f|
+.row
+ .col-lg-8
+ %h1.page-title.gl-font-size-h-display
+ = _("Report abuse to administrator")
+ %p
+ = _("Please use this form to report to the administrator users who create spam issues, comments or behave inappropriately.")
+ = _("A member of the abuse team will review your report as soon as possible.")
+
+= gitlab_ui_form_for @abuse_report, html: { class: 'js-quick-submit'} do |f|
= form_errors(@abuse_report)
= f.hidden_field :user_id
+ = f.hidden_field :category
+
.form-group.row
- .col-sm-2.col-form-label
- = f.label :user_id
- .col-sm-10
+ .col-lg-8
+ = f.label :reported_user
+
- name = "#{@abuse_report.user.name} (@#{@abuse_report.user.username})"
= text_field_tag :user_name, name, class: "form-control", readonly: true
.form-group.row
- .col-sm-2.col-form-label
- = f.label :message
- .col-sm-10
- = f.text_area :message, class: "form-control", rows: 2, required: true, value: sanitize(@ref_url)
+ .col-lg-8
+ = f.label :reported_from
+ = f.text_field :reported_from_url, class: "form-control", readonly: true
+ .form-group.row
+ .col-lg-8
+ = f.label :reason
+ = f.text_area :message, class: "form-control", rows: 4, required: true
.form-text.text-muted
- = _("Explain the problem. If appropriate, provide a link to the relevant issue or comment.")
+ = _("Explain why you're reporting the user.")
- .form-actions
- = f.submit _("Send report"), pajamas_button: true
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm) do
+ = _('Send report')
+ = render Pajamas::ButtonComponent.new(href: @abuse_report.reported_from_url, button_options: { class: 'gl-ml-3' }) do
+ = _('Cancel')
diff --git a/app/views/admin/application_settings/_kroki.html.haml b/app/views/admin/application_settings/_kroki.html.haml
index f1f6dd34401..e1f5802a407 100644
--- a/app/views/admin/application_settings/_kroki.html.haml
+++ b/app/views/admin/application_settings/_kroki.html.haml
@@ -32,4 +32,4 @@
- kroki_available_formats.each do |format|
= f.gitlab_ui_checkbox_component format[:name], format[:label]
- = f.submit _('Save changes'), class: "btn gl-button btn-confirm"
+ = f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml
index 332d3a94b92..b67cc29f296 100644
--- a/app/views/admin/application_settings/_repository_check.html.haml
+++ b/app/views/admin/application_settings/_repository_check.html.haml
@@ -19,33 +19,15 @@
%h4= _("Housekeeping")
.form-group
- help_text = _("Run housekeeping tasks to automatically optimize Git repositories. Disabling this option will cause performance to degenerate over time.")
- - help_link = link_to _('Learn more.'), help_page_path('administration/housekeeping.md', anchor: 'configure-push-based-maintenance'), target: '_blank', rel: 'noopener noreferrer'
+ - help_link = link_to _('Learn more.'), help_page_path('administration/housekeeping.md', anchor: 'heuristical-housekeeping'), 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 }
- - if Feature.enabled?(:optimized_housekeeping)
- .form-group
- = f.label :housekeeping_incremental_repack_period, _('Optimize repository period'), class: 'label-bold'
- = f.number_field :housekeeping_incremental_repack_period, class: 'form-control gl-form-input'
- .form-text.text-muted
- = _('Number of Git pushes after which Gitaly is asked to optimize a repository.')
- - else
- .form-group
- = f.label :housekeeping_incremental_repack_period, 'Incremental repack period', class: 'label-bold'
- = f.number_field :housekeeping_incremental_repack_period, class: 'form-control gl-form-input'
- .form-text.text-muted
- = html_escape(s_('Number of Git pushes after which an incremental %{code_start}git repack%{code_end} is run.')) % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe }
- .form-group
- = f.label :housekeeping_full_repack_period, 'Full repack period', class: 'label-bold'
- = f.number_field :housekeeping_full_repack_period, class: 'form-control gl-form-input'
- .form-text.text-muted
- = html_escape(s_('Number of Git pushes after which a full %{code_start}git repack%{code_end} is run.')) % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe }
- .form-group
- = f.label :housekeeping_gc_period, _('Git GC period'), class: 'label-bold'
- = f.number_field :housekeeping_gc_period, class: 'form-control gl-form-input'
- .form-text.text-muted
- = html_escape(s_('Number of Git pushes after which %{code_start}git gc%{code_end} is run.')) % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe }
-
+ .form-group
+ = f.label :housekeeping_optimize_repository_period, _('Optimize repository period'), class: 'label-bold'
+ = f.number_field :housekeeping_optimize_repository_period, class: 'form-control gl-form-input'
+ .form-text.text-muted
+ = _('Number of Git pushes after which Gitaly is asked to optimize a repository.')
.sub-section
%h4= s_("AdminSettings|Inactive project deletion")
.js-inactive-project-deletion-form{ data: inactive_projects_deletion_data(@application_setting) }
diff --git a/app/views/admin/application_settings/_user_restrictions.html.haml b/app/views/admin/application_settings/_user_restrictions.html.haml
index 82f5e6def9f..c35056383fa 100644
--- a/app/views/admin/application_settings/_user_restrictions.html.haml
+++ b/app/views/admin/application_settings/_user_restrictions.html.haml
@@ -4,3 +4,4 @@
= 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 new users to create top-level groups")
+ = form.gitlab_ui_checkbox_component :user_defaults_to_private_profile, _("Make new users' profiles private by default")
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 9c8770b8998..e01126e32e1 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -41,7 +41,7 @@
= f.gitlab_ui_checkbox_component :project_export_enabled, s_('AdminSettings|Enabled')
.form-group{ data: { testid: 'bulk-import' } }
- = f.label :bulk_import, s_('AdminSettings|Enable migrating GitLab groups and projects by direct transfer'), class: 'gl-font-weight-bold'
+ = f.label :bulk_import, s_('AdminSettings|Allow migrating GitLab groups and projects by direct transfer'), class: 'gl-font-weight-bold'
= f.gitlab_ui_checkbox_component :bulk_import_enabled, s_('AdminSettings|Enabled')
.form-group
diff --git a/app/views/admin/application_settings/ci/_header.html.haml b/app/views/admin/application_settings/ci/_header.html.haml
index 79c07f491fc..9e8caf0e0b7 100644
--- a/app/views/admin/application_settings/ci/_header.html.haml
+++ b/app/views/admin/application_settings/ci/_header.html.haml
@@ -8,13 +8,13 @@
%p
= _('Variables store information, like passwords and secret keys, that you can use in job scripts. All projects on the instance can use these variables.')
- = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'add-a-cicd-variable-to-an-instance'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'for-an-instance'), target: '_blank', rel: 'noopener noreferrer'
%p
= _('Variables can be:')
%ul
%li
= html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or protected tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'protected-cicd-variables'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
%li
= html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
= link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml
index 0414382a108..bd0ce766f81 100644
--- a/app/views/admin/application_settings/ci_cd.html.haml
+++ b/app/views/admin/application_settings/ci_cd.html.haml
@@ -9,7 +9,7 @@
.settings-content
- if ci_variable_protected_by_default?
%p.settings-message.text-center
- - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/index', anchor: 'protected-cicd-variables') }
+ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable') }
= s_('Environment variables on this GitLab instance are configured to be %{link_start}protected%{link_end} by default.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
#js-instance-variables{ data: { endpoint: admin_ci_variables_path, maskable_regex: ci_variable_maskable_regex, protected_by_default: ci_variable_protected_by_default?.to_s} }
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index 8c9d54cd5d8..dceee07019c 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -75,6 +75,8 @@
= render 'admin/application_settings/external_authorization_service_form', expanded: expanded_by_default?
+= render_if_exists 'admin/application_settings/scim'
+
%section.settings.as-terminal.no-animate#js-terminal-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml
deleted file mode 100644
index 4e05eb31010..00000000000
--- a/app/views/admin/broadcast_messages/_form.html.haml
+++ /dev/null
@@ -1,67 +0,0 @@
-#broadcast-message-preview
- = render 'preview'
-
-= gitlab_ui_form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f|
- = form_errors(@broadcast_message)
-
- .form-group.row.mt-4
- .col-sm-2.col-form-label
- = f.label :message
- .col-sm-10
- = f.text_area :message, class: "form-control gl-form-input js-autosize js-broadcast-message-message",
- required: true,
- dir: 'auto',
- data: { preview_path: preview_admin_broadcast_messages_path }
- .form-group.row
- .col-sm-2.col-form-label
- = f.label :broadcast_type, _('Type')
- .col-sm-10
- = f.select :broadcast_type, broadcast_type_options, {}, class: 'form-control js-broadcast-message-type'
- .form-group.row.js-broadcast-message-background-color-form-group{ class: ('hidden' unless @broadcast_message.banner?) }
- .col-sm-2.col-form-label
- = f.label :theme, _("Theme")
- .col-sm-10
- .input-group
- = f.select :theme, broadcast_theme_options, {}, class: 'form-control js-broadcast-message-theme'
-
- .form-group.row.js-broadcast-message-dismissable-form-group{ class: ('hidden' unless @broadcast_message.banner?) }
- .col-sm-2.col-form-label.pt-0
- = f.label :starts_at, _("Dismissable")
- .col-sm-10
- = f.gitlab_ui_checkbox_component :dismissable, _('Allow users to dismiss the broadcast message')
- - if Feature.enabled?(:role_targeted_broadcast_messages)
- .form-group.row
- .col-sm-2.col-form-label
- = f.label :target_access_levels, _('Target roles')
- .col-sm-10
- - target_access_level_options.each do |human_access_level, access_level|
- = f.gitlab_ui_checkbox_component :target_access_levels, human_access_level, checked_value: access_level, unchecked_value: false, checkbox_options: { multiple: true }
- .form-text.text-muted
- = _('The broadcast message displays only to users in projects and groups who have these roles.')
- .form-group.row.js-toggle-colors-container.toggle-colors.hide
- .col-sm-2.col-form-label
- = f.label :font, _("Font Color")
- .col-sm-10
- = f.color_field :font, class: "form-control gl-form-input text-font-color"
- .form-group.row
- .col-sm-2.col-form-label
- = f.label :target_path, _('Target Path')
- .col-sm-10
- = f.text_field :target_path, class: "form-control gl-form-input"
- .form-text.text-muted
- = _('Paths can contain wildcards, like */welcome')
- .form-group.row
- .col-sm-2.col-form-label
- = f.label :starts_at, _("Starts at (UTC)")
- .col-sm-10.datetime-controls
- = f.datetime_select :starts_at, {}, class: 'form-control form-control-inline'
- .form-group.row
- .col-sm-2.col-form-label
- = f.label :ends_at, _("Ends at (UTC)")
- .col-sm-10.datetime-controls
- = f.datetime_select :ends_at, {}, class: 'form-control form-control-inline'
- .form-actions
- - if @broadcast_message.persisted?
- = f.submit _("Update broadcast message"), pajamas_button: true
- - else
- = f.submit _("Add broadcast message"), pajamas_button: true
diff --git a/app/views/admin/broadcast_messages/_table.html.haml b/app/views/admin/broadcast_messages/_table.html.haml
deleted file mode 100644
index c5cd333f9dd..00000000000
--- a/app/views/admin/broadcast_messages/_table.html.haml
+++ /dev/null
@@ -1,38 +0,0 @@
-- targeted_broadcast_messages_enabled = Feature.enabled?(:role_targeted_broadcast_messages)
-
-- if @broadcast_messages.any?
- .table-responsive
- %table.table.b-table.gl-table
- %thead
- %tr
- %th= _('Status')
- %th= _('Preview')
- %th= _('Starts')
- %th= _('Ends')
- - if targeted_broadcast_messages_enabled
- %th= _('Target roles')
- %th= _('Target Path')
- %th= _('Type')
- %th &nbsp;
- %tbody
- - @broadcast_messages.each do |message|
- %tr
- %td
- = broadcast_message_status(message)
- %td
- = broadcast_message(message, preview: true)
- %td
- = message.starts_at
- %td
- = message.ends_at
- - if targeted_broadcast_messages_enabled
- %td
- = target_access_levels_display(message.target_access_levels)
- %td
- = message.target_path
- %td
- = message.broadcast_type.capitalize
- %td.gl-white-space-nowrap<
- = link_to sprite_icon('pencil', css_class: 'gl-icon'), edit_admin_broadcast_message_path(message), title: _('Edit'), class: 'btn btn-icon gl-button'
- = link_to sprite_icon('remove', css_class: 'gl-icon'), admin_broadcast_message_path(message), method: :delete, remote: true, title: _('Remove'), class: 'js-remove-tr btn btn-icon gl-button btn-danger gl-ml-3'
- = paginate @broadcast_messages, theme: 'gitlab'
diff --git a/app/views/admin/broadcast_messages/edit.html.haml b/app/views/admin/broadcast_messages/edit.html.haml
index 28301833f7d..bef435c07f7 100644
--- a/app/views/admin/broadcast_messages/edit.html.haml
+++ b/app/views/admin/broadcast_messages/edit.html.haml
@@ -1,19 +1,15 @@
- breadcrumb_title _("Messages")
- page_title _("Broadcast Messages")
-- vue_app_enabled = Feature.enabled?(:vue_broadcast_messages, current_user)
-- if vue_app_enabled
- #js-broadcast-message{ data: {
- id: @broadcast_message.id,
- message: @broadcast_message.message,
- broadcast_type: @broadcast_message.broadcast_type,
- theme: @broadcast_message.theme,
- dismissable: @broadcast_message.dismissable.to_s,
- target_access_levels: @broadcast_message.target_access_levels,
- target_path: @broadcast_message.target_path,
- starts_at: @broadcast_message.starts_at,
- ends_at: @broadcast_message.ends_at,
- target_access_level_options: target_access_level_options.to_json,
- } }
-- else
- = render 'form'
+#js-broadcast-message{ data: {
+ id: @broadcast_message.id,
+ message: @broadcast_message.message,
+ broadcast_type: @broadcast_message.broadcast_type,
+ theme: @broadcast_message.theme,
+ dismissable: @broadcast_message.dismissable.to_s,
+ target_access_levels: @broadcast_message.target_access_levels,
+ target_path: @broadcast_message.target_path,
+ starts_at: @broadcast_message.starts_at,
+ ends_at: @broadcast_message.ends_at,
+ target_access_level_options: target_access_level_options.to_json,
+} }
diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml
index 7a005f9c982..2fb59570231 100644
--- a/app/views/admin/broadcast_messages/index.html.haml
+++ b/app/views/admin/broadcast_messages/index.html.haml
@@ -1,31 +1,25 @@
- breadcrumb_title _("Messages")
- page_title _("Broadcast Messages")
-- vue_app_enabled = Feature.enabled?(:vue_broadcast_messages, current_user)
%h1.page-title.gl-font-size-h-display
= _('Broadcast Messages')
%p.light
= _('Use banners and notifications to notify your users about scheduled maintenance, recent upgrades, and more.')
-- if vue_app_enabled
- #js-broadcast-messages{ data: {
- page: params[:page] || 1,
- target_access_level_options: target_access_level_options.to_json,
- messages_count: @broadcast_messages.total_count,
- messages: @broadcast_messages.map { |message| {
- id: message.id,
- status: broadcast_message_status(message),
- preview: broadcast_message(message, preview: true),
- starts_at: message.starts_at.to_s,
- ends_at: message.ends_at.to_s,
- target_roles: target_access_levels_display(message.target_access_levels),
- target_path: message.target_path,
- type: message.broadcast_type.capitalize,
- edit_path: edit_admin_broadcast_message_path(message),
- delete_path: admin_broadcast_message_path(message) + '.js'
- } }.to_json
- } }
-- else
- = render 'form'
- %br.clearfix
- = render 'table'
+#js-broadcast-messages{ data: {
+ page: params[:page] || 1,
+ target_access_level_options: target_access_level_options.to_json,
+ messages_count: @broadcast_messages.total_count,
+ messages: @broadcast_messages.map { |message| {
+ id: message.id,
+ status: broadcast_message_status(message),
+ preview: broadcast_message(message, preview: true),
+ starts_at: message.starts_at.to_s,
+ ends_at: message.ends_at.to_s,
+ target_roles: target_access_levels_display(message.target_access_levels),
+ target_path: message.target_path,
+ type: message.broadcast_type.capitalize,
+ edit_path: edit_admin_broadcast_message_path(message),
+ delete_path: admin_broadcast_message_path(message) + '.js'
+ } }.to_json
+} }
diff --git a/app/views/admin/dashboard/_stats_users_table.html.haml b/app/views/admin/dashboard/_stats_users_table.html.haml
new file mode 100644
index 00000000000..473384b8961
--- /dev/null
+++ b/app/views/admin/dashboard/_stats_users_table.html.haml
@@ -0,0 +1,49 @@
+%table.table.gl-text-gray-500
+ %tr
+ %td.gl-p-5!
+ = s_('AdminArea|Users without a Group and Project')
+ = render_if_exists 'admin/dashboard/included_free_in_license_tooltip'
+ %td.gl-text-right{ class: 'gl-p-5!' }
+ = @users_statistics&.without_groups_and_projects
+ = render_if_exists 'admin/dashboard/minimal_access_stats_row', users_statistics: @users_statistics
+ %tr
+ %td.gl-p-5!
+ = s_('AdminArea|Users with highest role')
+ %strong
+ = s_('AdminArea|Reporter')
+ %td.gl-text-right{ class: 'gl-p-5!' }
+ = @users_statistics&.with_highest_role_reporter
+ %tr
+ %td.gl-p-5!
+ = s_('AdminArea|Users with highest role')
+ %strong
+ = s_('AdminArea|Developer')
+ %td.gl-text-right{ class: 'gl-p-5!' }
+ = @users_statistics&.with_highest_role_developer
+ %tr
+ %td.gl-p-5!
+ = s_('AdminArea|Users with highest role')
+ %strong
+ = s_('AdminArea|Maintainer')
+ %td.gl-text-right{ class: 'gl-p-5!' }
+ = @users_statistics&.with_highest_role_maintainer
+ %tr
+ %td.gl-p-5!
+ = s_('AdminArea|Users with highest role')
+ %strong
+ = s_('AdminArea|Owner')
+ %td.gl-text-right{ class: 'gl-p-5!' }
+ = @users_statistics&.with_highest_role_owner
+ %tr
+ %td.gl-p-5!
+ = s_('AdminArea|Users with highest role')
+ %strong
+ = s_('AdminArea|Guest')
+ = render_if_exists 'admin/dashboard/included_free_in_license_tooltip'
+ %td.gl-text-right{ class: 'gl-p-5!' }
+ = @users_statistics&.with_highest_role_guest
+ %tr
+ %td.gl-p-5!
+ = s_('AdminArea|Bots')
+ %td.gl-text-right{ class: 'gl-p-5!' }
+ = @users_statistics&.bots
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 27ae7d523b9..8afddd99451 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -164,10 +164,11 @@
= _('Rails')
%span.float-right
#{Rails::VERSION::STRING}
- %p
- = ApplicationRecord.database.human_adapter_name
- %span.float-right
- = ApplicationRecord.database.version
+ - database_versions.each do |database_name, database|
+ %p
+ #{database[:adapter_name]} (#{database_name})
+ %span.float-right
+ = database[:version]
%p
= _('Redis')
%span.float-right
diff --git a/app/views/admin/dashboard/stats.html.haml b/app/views/admin/dashboard/stats.html.haml
index e0701812ba3..0a5a425397f 100644
--- a/app/views/admin/dashboard/stats.html.haml
+++ b/app/views/admin/dashboard/stats.html.haml
@@ -2,63 +2,15 @@
%h3.gl-my-6
= s_('AdminArea|Users statistics')
+
+= render 'admin/dashboard/stats_users_table', user_statistics: @users_statistics
+
+%p.gl-font-weight-bold.gl-mt-8
+ = s_('AdminArea|Totals')
+
%table.table.gl-text-gray-500
- %tr
- %td.gl-p-5!
- = s_('AdminArea|Users without a Group and Project')
- = render_if_exists 'admin/dashboard/included_free_in_license_tooltip'
- %td.gl-text-right{ class: 'gl-p-5!' }
- = @users_statistics&.without_groups_and_projects
- = render_if_exists 'admin/dashboard/minimal_access_stats_row', users_statistics: @users_statistics
- %tr
- %td.gl-p-5!
- = s_('AdminArea|Users with highest role')
- %strong
- = s_('AdminArea|Guest')
- = render_if_exists 'admin/dashboard/included_free_in_license_tooltip'
- %td.gl-text-right{ class: 'gl-p-5!' }
- = @users_statistics&.with_highest_role_guest
- %tr
- %td.gl-p-5!
- = s_('AdminArea|Users with highest role')
- %strong
- = s_('AdminArea|Reporter')
- %td.gl-text-right{ class: 'gl-p-5!' }
- = @users_statistics&.with_highest_role_reporter
- %tr
- %td.gl-p-5!
- = s_('AdminArea|Users with highest role')
- %strong
- = s_('AdminArea|Developer')
- %td.gl-text-right{ class: 'gl-p-5!' }
- = @users_statistics&.with_highest_role_developer
- %tr
- %td.gl-p-5!
- = s_('AdminArea|Users with highest role')
- %strong
- = s_('AdminArea|Maintainer')
- %td.gl-text-right{ class: 'gl-p-5!' }
- = @users_statistics&.with_highest_role_maintainer
- %tr
- %td.gl-p-5!
- = s_('AdminArea|Users with highest role')
- %strong
- = s_('AdminArea|Owner')
- %td.gl-text-right{ class: 'gl-p-5!' }
- = @users_statistics&.with_highest_role_owner
- %tr
- %td.gl-p-5!
- = s_('AdminArea|Bots')
- %td.gl-text-right{ class: 'gl-p-5!' }
- = @users_statistics&.bots
- = render_if_exists 'admin/dashboard/billable_users_row'
- %tr.bg-gray-light.gl-text-gray-900
- %td.gl-p-5!
- %strong
- = s_('AdminArea|Active users')
- %td.gl-text-right{ class: 'gl-p-5!' }
- %strong
- = @users_statistics&.active
+ = render_if_exists 'admin/dashboard/stats_active_users_row', users_statistics: @users_statistics
+
%tr.bg-gray-light.gl-text-gray-900
%td.gl-p-5!
%strong
@@ -70,6 +22,8 @@
%td.gl-p-5!
%strong
= s_('AdminArea|Total users')
+ %span
+ (#{s_('AdminArea|active users + blocked users')})
%td.gl-text-right{ class: 'gl-p-5!' }
%strong
= @users_statistics&.total
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 829e9f508e0..bfa17daf1c2 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -135,7 +135,7 @@
- c.header do
= s_('ProjectSettings|Transfer project')
- c.body do
- = form_for @project, url: transfer_admin_project_path(@project), method: :put do |f|
+ = gitlab_ui_form_for @project, url: transfer_admin_project_path(@project), method: :put do |f|
.form-group.row
.col-sm-3.col-form-label
= f.label :new_namespace_id, _("Namespace")
@@ -147,13 +147,13 @@
.form-group.row
.offset-sm-3.col-sm-9
- = f.submit _('Transfer'), class: 'gl-button btn btn-confirm'
+ = f.submit _('Transfer'), pajamas_button: true
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5 repository-check' }) do |c|
- c.header do
= _("Repository check")
- c.body do
- = form_for @project, url: repository_check_admin_project_path(@project), method: :post do |f|
+ = gitlab_ui_form_for @project, url: repository_check_admin_project_path(@project), method: :post do |f|
.form-group
- if @project.last_repository_check_at.nil?
= _("This repository has never been checked.")
@@ -167,7 +167,7 @@
= link_to sprite_icon('question-o'), help_page_path('administration/repository_checks')
.form-group
- = f.submit _('Trigger repository check'), class: 'gl-button btn btn-confirm'
+ = f.submit _('Trigger repository check'), pajamas_button: true
.col-md-6
- if @group
diff --git a/app/views/admin/runners/edit.html.haml b/app/views/admin/runners/edit.html.haml
index ccdfe67ea77..e586a7a965e 100644
--- a/app/views/admin/runners/edit.html.haml
+++ b/app/views/admin/runners/edit.html.haml
@@ -24,7 +24,8 @@
dismissible: false,
title: project.full_name) do |c|
= c.actions do
- = link_to _('Disable'), admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'btn gl-alert-action btn-confirm btn-md gl-button'
+ = render Pajamas::ButtonComponent.new(variant: :confirm, href: admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete) do
+ = _('Disable')
%table.table{ data: { testid: 'unassigned-projects' } }
%thead
@@ -47,7 +48,8 @@
= project.full_name
%td
.float-right
- = form_for project.runner_projects.new, url: admin_namespace_project_runner_projects_path(project.namespace, project), method: :post do |f|
+ = gitlab_ui_form_for project.runner_projects.new, url: admin_namespace_project_runner_projects_path(project.namespace, project), method: :post do |f|
= f.hidden_field :runner_id, value: @runner.id
- = f.submit _('Enable'), class: 'gl-button btn btn-sm'
+ = render Pajamas::ButtonComponent.new(size: :small, type: :submit) do
+ = _('Enable')
= paginate_without_count @projects
diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml
index 2d0ea585735..183667679b9 100644
--- a/app/views/admin/spam_logs/_spam_log.html.haml
+++ b/app/views/admin/spam_logs/_spam_log.html.haml
@@ -23,19 +23,36 @@
= truncate(spam_log.description, length: 100)
%td
- if user
- = link_to _('Remove user'), admin_spam_log_path(spam_log, remove_user: true),
- data: { confirm: _("USER %{user_name} WILL BE REMOVED! Are you sure?") % { user_name: user.name }, confirm_btn_variant: 'danger' }, aria: { label: _('Remove user') }, method: :delete, class: "gl-button btn btn-sm btn-danger"
+ = render Pajamas::ButtonComponent.new(size: :small,
+ variant: :danger,
+ method: :delete,
+ href: admin_spam_log_path(spam_log, remove_user: true),
+ button_options: { data: { confirm: _("USER %{user_name} WILL BE REMOVED! Are you sure?") % { user_name: user.name }, confirm_btn_variant: 'danger' }, aria: { label: _('Remove user') } }) do
+ = _('Remove user')
%td
-# TODO: Remove conditonal once spamcheck supports this https://gitlab.com/gitlab-com/gl-security/engineering-and-research/automation-team/spam/spamcheck/-/issues/190
- if akismet_enabled?
- if spam_log.submitted_as_ham?
- .gl-button.btn.btn-default.btn-sm.disabled.gl-mb-3
+ = render Pajamas::ButtonComponent.new(size: :small,
+ button_options: { class: 'disabled gl-mb-3'}) do
= _("Submitted as ham")
- else
- = link_to _('Submit as ham'), mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'gl-button btn btn-default btn-sm gl-mb-3'
+ = render Pajamas::ButtonComponent.new(size: :small,
+ method: :post,
+ href: mark_as_ham_admin_spam_log_path(spam_log),
+ button_options: { class: ' gl-mb-3' }) do
+ = _('Submit as ham')
- if user && !user.blocked?
- = link_to _('Block user'), block_admin_user_path(user), data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')}, method: :put, class: "gl-button btn btn-default btn-sm gl-mb-3"
+ = render Pajamas::ButtonComponent.new(size: :small,
+ method: :put,
+ href: block_admin_user_path(user),
+ button_options: { class: 'gl-mb-3', data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')} }) do
+ = _('Block user')
- else
- .gl-button.btn.btn-default.btn-sm.disabled.gl-mb-3
- Already blocked
- = link_to _('Remove log'), [:admin, spam_log], remote: true, method: :delete, class: "gl-button btn btn-default btn-sm btn-close js-remove-tr"
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'disabled gl-mb-3'}) do
+ = _("Already blocked")
+ = render Pajamas::ButtonComponent.new(size: :small,
+ method: :delete,
+ href: [:admin, spam_log],
+ button_options: { class: 'js-remove-tr', remote: true, }) do
+ = _('Remove log')
diff --git a/app/views/admin/topics/index.html.haml b/app/views/admin/topics/index.html.haml
index 2f39f27208e..6d64fa1983f 100644
--- a/app/views/admin/topics/index.html.haml
+++ b/app/views/admin/topics/index.html.haml
@@ -1,4 +1,5 @@
- page_title _("Topics")
+- add_page_specific_style 'page_bundles/search'
.top-area
.nav-controls.gl-w-full.gl-mt-3.gl-mb-3
diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml
index b255354f2c1..472ba2f84a0 100644
--- a/app/views/admin/users/_access_levels.html.haml
+++ b/app/views/admin/users/_access_levels.html.haml
@@ -10,6 +10,7 @@
.form-group.gl-form-group{ role: 'group' }
= f.gitlab_ui_checkbox_component :can_create_group, s_('AdminUsers|Can create group')
+ = f.gitlab_ui_checkbox_component :private_profile, s_('AdminUsers|Private profile')
%fieldset.form-group.gl-form-group
%legend.col-form-label.col-form-label
diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml
index 96e6a264d8e..73027441fe6 100644
--- a/app/views/admin/users/_users.html.haml
+++ b/app/views/admin/users/_users.html.haml
@@ -48,7 +48,8 @@
.nav-controls
= render_if_exists 'admin/users/admin_email_users'
= render_if_exists 'admin/users/admin_export_user_permissions'
- = link_to s_('AdminUsers|New user'), new_admin_user_path, class: 'btn gl-button btn-confirm btn-search float-right'
+ = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_admin_user_path) do
+ = s_('AdminUsers|New user')
.filtered-search-block.row-content-block.border-top-0
= form_tag admin_users_path, method: :get do
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index 7edea81a123..f7d4121e6e0 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -76,6 +76,10 @@
%strong
= @user.can_create_group ? _('Yes') : _('No')
%li
+ %span.light= _('Private profile:')
+ %strong
+ = @user.private_profile ? _('Yes') : _('No')
+ %li
%span.light= _('Personal projects limit:')
%strong
= @user.projects_limit
diff --git a/app/views/ci/group_variables/_index.html.haml b/app/views/ci/group_variables/_index.html.haml
index eb49a9a0261..c8c970f3c2f 100644
--- a/app/views/ci/group_variables/_index.html.haml
+++ b/app/views/ci/group_variables/_index.html.haml
@@ -1,4 +1,4 @@
-- variables = @project.group.self_and_ancestors.map(&:variables).flatten
+- variables = @project.group.self_and_ancestors.flat_map(&:variables)
.ci-variable-table
%table.gl-table.gl-w-full.gl-table-layout-fixed
diff --git a/app/views/ci/status/_badge.html.haml b/app/views/ci/status/_badge.html.haml
index 5114387984b..e3b409dea76 100644
--- a/app/views/ci/status/_badge.html.haml
+++ b/app/views/ci/status/_badge.html.haml
@@ -1,7 +1,7 @@
- status = local_assigns.fetch(:status)
- link = local_assigns.fetch(:link, true)
- title = local_assigns.fetch(:title, nil)
-- css_classes = "ci-status ci-#{status.group} #{'has-tooltip' if title.present?}"
+- css_classes = "gl-display-inline-flex gl-align-items-center gl-gap-2 gl-line-height-0 gl-px-3 gl-py-2 gl-rounded-base ci-status ci-#{status.group} #{'has-tooltip' if title.present?}"
- if link && status.has_details?
= link_to status.details_path, class: css_classes, title: title, data: { html: title.present? } do
diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml
index 37043a207ff..65e57d68288 100644
--- a/app/views/ci/variables/_content.html.haml
+++ b/app/views/ci/variables/_content.html.haml
@@ -2,7 +2,7 @@
= link_to _('Learn more.'), help_page_path('ci/variables/index'), target: '_blank', rel: 'noopener noreferrer'
%p
= _('Variables can have several attributes.')
- = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'add-a-cicd-variable-to-an-instance'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'define-a-cicd-variable-in-the-ui'), target: '_blank', rel: 'noopener noreferrer'
%ul
%li
= html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or protected tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index fdbf5132d40..af98025d257 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -4,7 +4,7 @@
= render Pajamas::AlertComponent.new(variant: :warning, show_icon: false, dismissible: false,
alert_options: { class: 'gl-mb-3'}) do |c|
= c.body do
- - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/index', anchor: 'protected-cicd-variables') }
+ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable') }
= _('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- is_group = !@group.nil?
@@ -23,10 +23,10 @@
aws_tip_deploy_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'deploy-your-application-to-ecs'),
aws_tip_commands_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'use-an-image-to-run-aws-commands'),
aws_tip_learn_link: help_page_path('ci/cloud_deployment/index.md'),
- contains_variable_reference_link: help_page_path('ci/variables/index', anchor: 'expand-cicd-variables'),
- protected_environment_variables_link: help_page_path('ci/variables/index', anchor: 'protected-cicd-variables'),
+ contains_variable_reference_link: help_page_path('ci/variables/index', anchor: 'prevent-cicd-variable-expansion'),
+ protected_environment_variables_link: help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable'),
masked_environment_variables_link: help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'),
- environment_scope_link: help_page_path('ci/environments/index', anchor: 'scope-environments-with-specs') } }
+ environment_scope_link: help_page_path('ci/environments/index', anchor: 'limit-the-environment-scope-of-a-cicd-variable') } }
- if !@group && @project.group
.settings-header.border-top.gl-mt-6
diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml
index 8eba398fd13..ddc3b7d05e1 100644
--- a/app/views/clusters/clusters/_advanced_settings.html.haml
+++ b/app/views/clusters/clusters/_advanced_settings.html.haml
@@ -14,7 +14,7 @@
= html_escape(s_('ClusterIntegration|Manage your Kubernetes cluster by visiting %{provider_link}')) % { provider_link: provider_link }
.sub-section.form-group
- = form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'cluster_management_form' } do |field|
+ = gitlab_ui_form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'cluster_management_form' } do |field|
%h4
= s_('ClusterIntegration|Cluster management project')
@@ -24,7 +24,7 @@
.text-muted
= html_escape(s_('ClusterIntegration|A cluster management project can be used to run deployment jobs with Kubernetes %{code_open}cluster-admin%{code_close} privileges.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
= link_to _('More information'), help_page_path('user/clusters/management_project.md'), target: '_blank', rel: 'noopener noreferrer'
- = field.submit _('Save changes'), class: 'btn gl-button btn-confirm'
+ = field.submit _('Save changes'), pajamas_button: true
.sub-section.form-group
%h4
@@ -35,7 +35,8 @@
= s_("ClusterIntegration|This is necessary if your integration has become out of sync. The cache is repopulated during the next CI job that requires namespace and service accounts.")
- else
= s_("ClusterIntegration|This is necessary to clear existing environment-namespace associations from clusters previously managed by GitLab.")
- = link_to(s_('ClusterIntegration|Clear cluster cache'), clusterable.clear_cluster_cache_path(@cluster), method: :delete, class: 'btn gl-button btn-confirm')
+ = render Pajamas::ButtonComponent.new(method: :delete, href: clusterable.clear_cluster_cache_path(@cluster)) do
+ = s_('ClusterIntegration|Clear cluster cache')
.sub-section.form-group
%h4.text-danger
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index 9dfeaa3d07d..f97c0e2b9b6 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -1,7 +1,7 @@
%li.todo.gl-hover-border-blue-200.gl-hover-bg-blue-50.gl-hover-cursor-pointer.gl-relative{ class: "todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo) }
.gl-display-flex.gl-flex-direction-column.gl-sm-flex-direction-row.gl-sm-align-items-center
.todo-item.gl-overflow-hidden.gl-overflow-x-auto.gl-align-self-center.gl-w-full{ data: { qa_selector: "todo_item_container" } }
- .todo-title.gl-pt-2.gl-pb-3.gl-px-2.gl-md-mb-1.gl-font-sm.gl-text-gray-500
+ .todo-title.gl-pt-2.gl-pb-3.gl-px-2.gl-md-mb-1.gl-font-sm.gl-text-secondary
= todo_target_state_pill(todo)
@@ -16,7 +16,7 @@
%span.todo-label
- if todo.target
- = link_to todo_target_name(todo), todo_target_path(todo), class: 'todo-target-link gl-text-gray-500! gl-text-decoration-none!', :'aria-describedby' => dom_id(todo) + "_describer", :'aria-label' => todo_target_aria_label(todo)
+ = link_to todo_target_name(todo), todo_target_path(todo), class: 'todo-target-link gl-text-secondary! gl-text-decoration-none!', :'aria-describedby' => dom_id(todo) + "_describer", :'aria-label' => todo_target_aria_label(todo)
- else
= _("(removed)")
@@ -25,29 +25,30 @@
= author_avatar(todo, size: 24)
.todo-note
- if todo_author_display?(todo)
- .author-name.bold.gl-display-inline
+ .author-name.bold.gl-display-inline<
- if todo.author
= link_to_author(todo, self_added: todo.self_added?)
- else
= _('(removed)')
+ - if todo.note.present?
+ \:
%span.action-name{ data: { qa_selector: "todo_action_name_content" } }<
- = todo_action_name(todo)
- - if todo.note.present?
- \:
- - unless todo.note.present? || todo.self_assigned?
- \.
+ - if !todo.note.present?
+ = todo_action_name(todo)
+ - unless todo.self_assigned?
+ \.
- if todo.self_assigned?
%span.action-name<
= todo_self_addressing(todo)
\.
- if todo.note.present?
- %span.action-description.gl-font-style-italic<
- = first_line_in_markdown(todo, :body, 100, is_todo: true, project: todo.project, group: todo.group)
+ %span.action-description<
+ = first_line_in_markdown(todo, :body, 125, is_todo: true, project: todo.project, group: todo.group)
.todo-timestamp.gl-white-space-nowrap.gl-sm-ml-3.gl-mt-2.gl-mb-2.gl-sm-my-0.gl-px-2.gl-sm-px-0
- %span.todo-timestamp.gl-font-sm.gl-text-gray-500
+ %span.todo-timestamp.gl-font-sm.gl-text-secondary
= todo_due_date(todo)
#{time_ago_with_tooltip(todo.created_at)}
diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml
index fd20ff9a418..f63f1aa9197 100644
--- a/app/views/devise/sessions/two_factor.html.haml
+++ b/app/views/devise/sessions/two_factor.html.haml
@@ -3,7 +3,7 @@
.login-box.gl-p-5
.login-body
- if @user.two_factor_otp_enabled?
- = form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if @user.two_factor_webauthn_u2f_enabled?}" }) do |f|
+ = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if @user.two_factor_webauthn_u2f_enabled?}" }) do |f|
- resource_params = params[resource_name].presence || params
= f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0)
%div
@@ -11,6 +11,6 @@
= f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', title: _('This field is required.'), data: { qa_selector: 'two_fa_code_field' }
%p.form-text.text-muted.hint= _("Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.")
.prepend-top-20
- = f.submit _("Verify code"), class: "gl-button btn btn-confirm", data: { qa_selector: 'verify_code_button' }
+ = f.submit _("Verify code"), pajamas_button: true, data: { qa_selector: 'verify_code_button' }
- if @user.two_factor_webauthn_u2f_enabled?
= render "authentication/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index a3a5fe690a7..b9fe61229bc 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -6,7 +6,7 @@
- if show_omniauth_providers && omniauth_providers_placement == :top
= render 'devise/shared/signup_omniauth_providers_top'
- = form_for(resource, as: "new_#{resource_name}", url: url, html: { class: 'new_user gl-show-field-errors js-arkose-labs-form', 'aria-live' => 'assertive' }, data: { testid: 'signup-form' }) do |f|
+ = gitlab_ui_form_for(resource, as: "new_#{resource_name}", url: url, html: { class: 'new_user gl-show-field-errors js-arkose-labs-form', 'aria-live' => 'assertive' }, data: { testid: 'signup-form' }) do |f|
.devise-errors
= render 'devise/shared/error_messages', resource: resource
- if Gitlab::CurrentSettings.invisible_captcha_enabled
@@ -72,7 +72,7 @@
= recaptcha_tags nonce: content_security_policy_nonce
.submit-container.gl-mt-5
- = f.submit button_text, class: 'btn gl-button btn-confirm gl-display-block gl-w-full', data: { qa_selector: 'new_user_register_button' }
+ = f.submit button_text, pajamas_button: true, class: 'gl-w-full', data: { qa_selector: 'new_user_register_button' }
- if Gitlab::CurrentSettings.sign_in_text.present? && Feature.enabled?(:restyle_login_page, @project)
.gl-pt-5
= markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text)
diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml
index 5c085555872..a96c8d6358b 100644
--- a/app/views/devise/shared/_signup_omniauth_provider_list.haml
+++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml
@@ -1,4 +1,4 @@
-- register_omniauth_params = Feature.enabled?(:update_oauth_registration_flow) ? { intent: :register } : {}
+- register_omniauth_params = { intent: :register }
- if Feature.enabled?(:restyle_login_page, @project)
.gl-text-center.gl-pt-5
%label.gl-font-weight-normal
diff --git a/app/views/errors/omniauth_error.html.haml b/app/views/errors/omniauth_error.html.haml
index 3090c823677..c48e12561a7 100644
--- a/app/views/errors/omniauth_error.html.haml
+++ b/app/views/errors/omniauth_error.html.haml
@@ -2,18 +2,16 @@
.container
= render partial: "shared/errors/graphic_422", formats: :svg
- %h3
+ %h2
= _('Sign-in using %{provider} auth failed') % { provider: @provider }
-
- %p.light.subtitle
- = _('Sign-in failed because %{error}.') % { error: @error }
-
- %p
- = _('Try logging in using your username or email. If you have forgotten your password, try recovering it')
-
- = link_to _('Sign in'), new_session_path(:user), class: 'gl-button btn primary'
- = link_to _('Recover password'), new_password_path(:user), class: 'gl-button btn secondary'
-
- %hr
- %p.light
- = _('If none of the options work, try contacting a GitLab administrator.')
+ .gl-mb-5
+ = @error
+ .gl-mb-5
+ = render Pajamas::ButtonComponent.new(variant: :confirm,
+ href: new_session_path(:user),
+ button_options: { class: 'gl-mr-2' }) do
+ = _('Sign in')
+ = render Pajamas::ButtonComponent.new(href: new_password_path(:user)) do
+ = _('Recover password')
+ %div
+ = _('If you are unable to sign in or recover your password, contact a GitLab administrator.')
diff --git a/app/views/groups/_delete_project_button.html.haml b/app/views/groups/_delete_project_button.html.haml
index 54a99319418..8321e86c44f 100644
--- a/app/views/groups/_delete_project_button.html.haml
+++ b/app/views/groups/_delete_project_button.html.haml
@@ -1 +1,2 @@
-= link_to _('Delete'), project, data: { confirm: remove_project_message(project) }, method: :delete, class: "btn gl-button btn-danger"
+= render Pajamas::ButtonComponent.new(href: project, variant: :danger, method: :delete, button_options: { data: { confirm: remove_project_message(project) } }) do
+ = _('Delete')
diff --git a/app/views/groups/_group_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml
index 0b26db64ffa..8547795b4b7 100644
--- a/app/views/groups/_group_admin_settings.html.haml
+++ b/app/views/groups/_group_admin_settings.html.haml
@@ -36,4 +36,4 @@
= f.gitlab_ui_checkbox_component :runner_registration_enabled,
s_('Runners|New group runners can be registered'),
checkbox_options: { checked: @group.runner_registration_enabled && !parent_disabled, disabled: parent_disabled },
- help_text: s_('Runners|Existing runners are not affected. To permit runner registration for all groups, enable this setting in the Admin Area in Settings &gt CI/CD.').html_safe
+ help_text: s_('Runners|Existing runners are not affected. To permit runner registration for all groups, enable this setting in the Admin Area in Settings &gt; CI/CD.').html_safe
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index 1494990e427..fac0fd3d2a4 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -12,6 +12,7 @@
= @group.name
%span.visibility-icon.gl-text-secondary.has-tooltip.gl-ml-2{ data: { container: 'body' }, title: visibility_icon_description(@group) }
= visibility_level_icon(@group.visibility_level, options: {class: 'icon'})
+ = render_if_exists 'shared/tier_badge', source: @group, source_type: 'Group'
.home-panel-metadata.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { qa_selector: 'group_id_content' }, itemprop: 'identifier' }
- if can?(current_user, :read_group, @group)
%span.gl-display-inline-block.gl-vertical-align-middle
@@ -24,25 +25,23 @@
- if current_user
.home-panel-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-center.gl-flex-wrap.gl-gap-3{ data: { testid: 'group-buttons' } }
- if current_user.admin?
- = link_to [:admin, @group], class: 'btn btn-default gl-button btn-icon', title: _('View group in admin area'),
- data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = sprite_icon('admin')
+ = render Pajamas::ButtonComponent.new(href: [:admin, @group], icon: 'admin', button_options: { title: _('View group in admin area'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } })
- if @notification_setting
.js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), group_id: @group.id, container_class: 'gl-vertical-align-top', no_flip: 'true' } }
- if can_create_subgroups
.gl-sm-w-auto.gl-w-full
- = link_to _("New subgroup"),
- new_group_path(parent_id: @group.id, anchor: 'create-group-pane'),
- class: "btn btn-default gl-button gl-sm-w-auto gl-w-full",
- data: { qa_selector: 'new_subgroup_button' }
+ = render Pajamas::ButtonComponent.new(href: new_group_path(parent_id: @group.id, anchor: 'create-group-pane'), button_options: { data: { qa_selector: 'new_subgroup_button' }, class: 'gl-sm-w-auto gl-w-full'}) do
+ = _("New subgroup")
+
- if can_create_projects
.gl-sm-w-auto.gl-w-full
- = link_to _("New project"), new_project_path(namespace_id: @group.id), class: "btn btn-confirm gl-button gl-sm-w-auto gl-w-full", data: { qa_selector: 'new_project_button' }
+ = render Pajamas::ButtonComponent.new(href: new_project_path(namespace_id: @group.id), variant: :confirm, button_options: { data: { qa_selector: 'new_project_button' }, class: 'gl-sm-w-auto gl-w-full' }) do
+ = _('New project')
- if @group.description.present?
.group-home-desc.mt-1
.home-panel-description
.home-panel-description-markdown.read-more-container{ itemprop: 'description' }
= markdown_field(@group, :description)
- %button.gl-button.btn.btn-link.js-read-more-trigger.d-lg-none{ type: "button" }
+ = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'js-read-more-trigger gl-lg-display-none' }) do
= _("Read more")
diff --git a/app/views/groups/_import_group_from_another_instance_panel.html.haml b/app/views/groups/_import_group_from_another_instance_panel.html.haml
index 4a4bdfc6714..24ba060a89a 100644
--- a/app/views/groups/_import_group_from_another_instance_panel.html.haml
+++ b/app/views/groups/_import_group_from_another_instance_panel.html.haml
@@ -4,7 +4,7 @@
.gl-border-l-solid.gl-border-r-solid.gl-border-t-solid.gl-border-gray-100.gl-border-1.gl-p-5.gl-mt-4
.gl-display-flex.gl-align-items-center
%h4.gl-display-flex
- = s_('GroupsNew|Import groups from another instance of GitLab')
+ = s_('GroupsNew|Import groups by direct transfer')
= link_to _('History'), history_import_bulk_imports_path, class: 'gl-link gl-ml-auto'
- if bulk_imports_disabled
@@ -30,12 +30,12 @@
= s_('GroupsNew|Not all related objects are migrated. %{docs_link_start}More info%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end }
%p.gl-mt-3
- = s_('GroupsNew|Provide credentials for another instance of GitLab to import your groups directly.')
+ = s_('GroupsNew|Provide credentials for the source instance to import from. You can provide this instance as a source to move groups in this instance.')
.form-group.gl-display-flex.gl-flex-direction-column
- = f.label :bulk_import_gitlab_url, s_('GroupsNew|GitLab source URL'), for: 'import_gitlab_url'
+ = f.label :bulk_import_gitlab_url, s_('GroupsNew|GitLab source instance URL'), for: 'import_gitlab_url'
= f.text_field :bulk_import_gitlab_url, disabled: bulk_imports_disabled, placeholder: 'https://gitlab.example.com', class: 'gl-form-input col-xs-12 col-sm-8',
required: true,
- title: s_('GroupsNew|Please fill in GitLab source URL.'),
+ title: s_('GroupsNew|Enter the URL for the source instance.'),
id: 'import_gitlab_url',
data: { qa_selector: 'import_gitlab_url' }
.form-group.gl-display-flex.gl-flex-direction-column
diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml
index 657a582bdc5..b75fda2f344 100644
--- a/app/views/groups/new.html.haml
+++ b/app/views/groups/new.html.haml
@@ -16,9 +16,8 @@
#import-group-pane.tab-pane
- if import_sources_enabled?
- - if BulkImports::Features.enabled?
- = render 'import_group_from_another_instance_panel'
- .gl-mt-7.gl-border-b-solid.gl-border-gray-100.gl-border-1
+ = render 'import_group_from_another_instance_panel'
+ .gl-mt-7.gl-border-b-solid.gl-border-gray-100.gl-border-1
= render 'import_group_from_file_panel'
- else
.nothing-here-block
diff --git a/app/views/groups/settings/repository/show.html.haml b/app/views/groups/settings/repository/show.html.haml
index a15652b3179..c6bf2d66683 100644
--- a/app/views/groups/settings/repository/show.html.haml
+++ b/app/views/groups/settings/repository/show.html.haml
@@ -8,5 +8,7 @@
= render "shared/deploy_tokens/index", group_or_project: @group, description: deploy_token_description
= render "default_branch", group: @group
+= render_if_exists "protected_branches/protected_branches", protected_branch_entity: @group
+
- if can?(current_user, :change_push_rules, @group)
= render "push_rules"
diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml
index b18b5f1574b..c968d84fc36 100644
--- a/app/views/ide/_show.html.haml
+++ b/app/views/ide/_show.html.haml
@@ -1,4 +1,4 @@
-- page_title _('IDE')
+- page_title _("IDE"), @project.full_name
- unless use_new_web_ide?
- add_page_specific_style 'page_bundles/build'
diff --git a/app/views/layouts/_loading_hints.html.haml b/app/views/layouts/_loading_hints.html.haml
index b1d1447ae2a..60ab6927fd2 100644
--- a/app/views/layouts/_loading_hints.html.haml
+++ b/app/views/layouts/_loading_hints.html.haml
@@ -18,4 +18,7 @@
-# See https://github.com/web-platform-tests/wpt/pull/36930
%link{ rel: 'preload', href: font_path('gitlab-sans/GitLabSans.woff2'), as: 'font', crossorigin: css_crossorigin }
%link{ rel: 'preload', href: font_path('jetbrains-mono/JetBrainsMono.woff2'), as: 'font', crossorigin: css_crossorigin }
+ %link{ rel: 'preload', href: font_path('jetbrains-mono/JetBrainsMono-Bold.woff2'), as: 'font', crossorigin: css_crossorigin }
+ %link{ rel: 'preload', href: font_path('jetbrains-mono/JetBrainsMono-Italic.woff2'), as: 'font', crossorigin: css_crossorigin }
+ %link{ rel: 'preload', href: font_path('jetbrains-mono/JetBrainsMono-BoldItalic.woff2'), as: 'font', crossorigin: css_crossorigin }
= preload_link_tag(path_to_stylesheet('fonts'), crossorigin: css_crossorigin)
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index bb1d051f71f..010ddd8da39 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -1,5 +1,10 @@
+- if show_super_sidebar?
+ - @left_sidebar = true
.layout-page.hide-when-top-nav-responsive-open{ class: page_with_sidebar_class }
- - if defined?(nav) && nav
+ - if show_super_sidebar?
+ - sidebar_data = super_sidebar_context(current_user).to_json
+ %aside.js-super-sidebar.nav-sidebar{ data: { root_path: root_path, sidebar: sidebar_data, toggle_new_nav_endpoint: profile_preferences_url } }
+ - elsif defined?(nav) && nav
= render "layouts/nav/sidebar/#{nav}"
.content-wrapper.content-wrapper-margin{ class: "#{@content_wrapper_class}" }
.mobile-overlay
diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml
index 22cc8027202..0b5c4730b64 100644
--- a/app/views/layouts/_snowplow.html.haml
+++ b/app/views/layouts/_snowplow.html.haml
@@ -7,7 +7,7 @@
;(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","#{asset_url('snowplow/sp.js')}","snowplow"));
+ n.src=w;g.parentNode.insertBefore(n,g)}}(window,document,"script","#{escaped_url(asset_url('snowplow/sp.js'))}","snowplow"));
window.snowplowOptions = #{Gitlab::Tracking.options(@group).to_json}
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 455d18a5ae8..fa79219df4a 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -11,7 +11,14 @@
= render "layouts/visual_review" if ENV['REVIEW_APPS_ENABLED']
= render 'peek/bar'
= header_message
- = render partial: "layouts/header/default", locals: { project: @project, group: @group }
+
+ - if show_super_sidebar? # TODO: Move this CSS to a better place
+ :css
+ body {
+ --header-height: 0px;
+ }
+ - else
+ = render partial: "layouts/header/default", locals: { project: @project, group: @group }
= render 'layouts/page', sidebar: sidebar, nav: nav
= footer_message
diff --git a/app/views/layouts/dashboard.html.haml b/app/views/layouts/dashboard.html.haml
index c10be282952..028c22fe9e5 100644
--- a/app/views/layouts/dashboard.html.haml
+++ b/app/views/layouts/dashboard.html.haml
@@ -1,6 +1,9 @@
- page_title _("Dashboard")
- header_title _("Dashboard"), root_path unless header_title
-- sidebar "dashboard"
-- @hide_breadcrumbs = true
+- if Feature.enabled?(:your_work_sidebar, current_user)
+ - @left_sidebar = true
+ - nav "your_work"
+- else
+ - @hide_breadcrumbs = true
= render template: "layouts/application"
diff --git a/app/views/layouts/explore.html.haml b/app/views/layouts/explore.html.haml
index 24751ab4e06..389dee853ba 100644
--- a/app/views/layouts/explore.html.haml
+++ b/app/views/layouts/explore.html.haml
@@ -1,7 +1,11 @@
- page_title _("Explore")
-- @hide_breadcrumbs = true
+
+- if current_user && Feature.enabled?(:your_work_sidebar, current_user)
+ - @left_sidebar = true
+ - nav "your_work"
- unless current_user
+ - @hide_breadcrumbs = true
- header_title _("Explore GitLab"), explore_root_path
= render template: "layouts/application"
diff --git a/app/views/layouts/header/_registration_enabled_callout.html.haml b/app/views/layouts/header/_registration_enabled_callout.html.haml
index 52c39fce961..5c70136a932 100644
--- a/app/views/layouts/header/_registration_enabled_callout.html.haml
+++ b/app/views/layouts/header/_registration_enabled_callout.html.haml
@@ -9,9 +9,7 @@
= c.body do
= _("Your GitLab instance allows anyone to register for an account, which is a security risk on public-facing GitLab instances. You should deactivate new sign ups if public users aren't expected to register for an account.")
= c.actions do
- = link_to general_admin_application_settings_path(anchor: 'js-signup-settings'), class: 'btn gl-alert-action btn-confirm btn-md gl-button' do
- %span.gl-button-text
- = _('Deactivate')
- %button.btn.gl-alert-action.btn-default.btn-md.gl-button.js-close
- %span.gl-button-text
- = _('Acknowledge')
+ = render Pajamas::ButtonComponent.new(variant: :confirm, href: general_admin_application_settings_path(anchor: 'js-signup-settings')) do
+ = _('Deactivate')
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-close gl-ml-3'}) do
+ = _('Acknowledge')
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 717175e8eb3..24b301fadce 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -7,14 +7,14 @@
%span.sidebar-context-title
= _('Admin Area')
%ul.sidebar-top-level-items{ data: { qa_selector: 'admin_overview_submenu_content' } }
- = nav_link(controller: %w[dashboard admin admin/projects users groups admin/topics jobs runners gitaly_servers cohorts], html_options: {class: 'home'}) do
+ = nav_link(controller: %w[dashboard admin admin/projects users groups admin/topics gitaly_servers cohorts], html_options: {class: 'home'}) do
= link_to admin_root_path, class: 'has-sub-items' do
.nav-icon-container
= sprite_icon('overview')
%span.nav-item-name
= _('Overview')
%ul.sidebar-sub-level-items
- = nav_link(controller: %w[dashboard admin admin/projects users groups jobs runners gitaly_servers cohorts], html_options: { class: "fly-out-top-item" }) do
+ = nav_link(controller: %w[dashboard admin admin/projects users groups gitaly_servers cohorts], html_options: { class: "fly-out-top-item" }) do
= link_to admin_root_path do
%strong.fly-out-top-item-name
= _('Overview')
@@ -39,18 +39,31 @@
= link_to admin_topics_path, title: _('Topics') do
%span
= _('Topics')
- = nav_link path: 'jobs#index' do
- = link_to admin_jobs_path, title: _('Jobs') do
+ = nav_link(controller: :gitaly_servers) do
+ = link_to admin_gitaly_servers_path, title: 'Gitaly Servers' do
%span
- = _('Jobs')
- = nav_link path: ['runners#index', 'runners#show'] do
+ = _('Gitaly Servers')
+
+ = nav_link(controller: %w[runners jobs]) do
+ = link_to admin_runners_path, class: 'has-sub-items' do
+ .nav-icon-container
+ = sprite_icon('rocket')
+ %span.nav-item-name
+ = _('CI/CD')
+ %ul.sidebar-sub-level-items
+ = nav_link(controller: %w[runners jobs], html_options: { class: "fly-out-top-item" }) do
+ = link_to admin_runners_path do
+ %strong.fly-out-top-item-name
+ = _('CI/CD')
+ %li.divider.fly-out-top-item
+ = nav_link(controller: :runners) do
= link_to admin_runners_path, title: _('Runners') do
%span
= _('Runners')
- = nav_link(controller: :gitaly_servers) do
- = link_to admin_gitaly_servers_path, title: 'Gitaly Servers' do
+ = nav_link(controller: :jobs) do
+ = link_to admin_jobs_path, title: _('Jobs') do
%span
- = _('Gitaly Servers')
+ = _('Jobs')
= nav_link(controller: admin_analytics_nav_links) do
= link_to admin_dev_ops_reports_path, data: { qa_selector: 'admin_analytics_link' }, class: 'has-sub-items' do
diff --git a/app/views/layouts/nav/sidebar/_your_work.html.haml b/app/views/layouts/nav/sidebar/_your_work.html.haml
new file mode 100644
index 00000000000..0eba5045ab1
--- /dev/null
+++ b/app/views/layouts/nav/sidebar/_your_work.html.haml
@@ -0,0 +1 @@
+= render partial: 'shared/nav/sidebar', object: Sidebars::YourWork::Panel.new(Sidebars::Context.new(current_user: current_user, container: nil))
diff --git a/app/views/layouts/oauth_error.html.haml b/app/views/layouts/oauth_error.html.haml
index 03b387f8181..8d241dfd207 100644
--- a/app/views/layouts/oauth_error.html.haml
+++ b/app/views/layouts/oauth_error.html.haml
@@ -3,35 +3,10 @@
%head
%meta{ :content => "width=device-width, initial-scale=1, maximum-scale=1", :name => "viewport" }
%title= yield(:title)
+ = stylesheet_link_tag 'application_utilities'
+ %style
+ = Rails.application.assets_manifest.find_sources('errors.css').first.to_s.html_safe
:css
- body {
- color: #666;
- text-align: center;
- font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
- margin: auto;
- font-size: 16px;
- }
-
- .container {
- margin: auto 20px;
- }
-
- h3 {
- color: #456;
- font-size: 22px;
- font-weight: 600;
- margin-bottom: 6px;
- }
-
- p {
- max-width: 470px;
- margin: 16px auto;
- }
-
- .subtitle {
- margin: 0 auto 20px;
- }
-
svg {
width: 280px;
height: 280px;
@@ -82,46 +57,5 @@
25% {opacity: 1;}
}
- .light {
- color: #8D8D8D;
- }
-
- hr {
- max-width: 600px;
- margin: 18px auto;
- border: 0;
- border-top: 1px solid #EEE;
- }
-
- .btn {
- padding: 8px 14px;
- border-radius: 3px;
- border: 1px solid;
- display: inline-block;
- text-decoration: none;
- margin: 4px 8px;
- font-size: 14px;
- }
-
- .primary {
- color: #fff;
- background-color: #1aaa55;
- border-color: #168f48;
- }
-
- .primary:hover {
- background-color: #168f48;
- }
-
- .secondary {
- color: #1aaa55;
- background-color: #fff;
- border-color: #1aaa55;
- }
-
- .secondary:hover {
- background-color: #f3fff8;
- }
-
%body
= yield
diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml
index 54b5ec85ccc..fd331d4b6c8 100644
--- a/app/views/layouts/snippets.html.haml
+++ b/app/views/layouts/snippets.html.haml
@@ -2,6 +2,10 @@
- header_title _("Snippets"), snippets_path
- snippets_upload_path = snippets_upload_path(@snippet, current_user)
+- if current_user && Feature.enabled?(:your_work_sidebar, current_user)
+ - @left_sidebar = true
+ - nav "your_work"
+
- content_for :page_specific_javascripts do
- if snippets_upload_path
= javascript_tag do
diff --git a/app/views/notify/access_token_expired_email.html.haml b/app/views/notify/access_token_expired_email.html.haml
index 1e7c07c2282..9c3ef4cfdff 100644
--- a/app/views/notify/access_token_expired_email.html.haml
+++ b/app/views/notify/access_token_expired_email.html.haml
@@ -1,7 +1,15 @@
%p
= _('Hi %{username}!') % { username: sanitize_name(@user.name) }
%p
- = _('One or more of your personal access tokens has expired.')
+ - if @token_names.empty?
+ = _('One or more of your personal access tokens has expired.')
+ - else
+ = _('The following personal access tokens have expired:')
+
+ %p
+ %ul
+ - @token_names.each do |token|
+ %li= token
%p
- - pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
+ - pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
= html_escape(_('You can create a new one or check them in your %{pat_link_start}personal access tokens%{pat_link_end} settings.')) % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe }
diff --git a/app/views/notify/access_token_expired_email.text.erb b/app/views/notify/access_token_expired_email.text.erb
index 4dc67e85dc2..6f6a9d38192 100644
--- a/app/views/notify/access_token_expired_email.text.erb
+++ b/app/views/notify/access_token_expired_email.text.erb
@@ -1,5 +1,13 @@
<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %>
-<%= _('One or more of your personal access tokens has expired.') %>
+<%- if @token_names.empty? -%>
+<%= _('One or more of your personal access tokens have expired.') %>
+<%- else -%>
+<%= _('The following personal access tokens have expired:') %>
+
+<%- @token_names.each do |token| -%>
+ - <%= token %>
+<%- end -%>
+<%- end -%>
<%= _('You can create a new one or check them in your personal access tokens settings %{pat_link}.') % { pat_link: @target_url } %>
diff --git a/app/views/notify/access_token_revoked_email.html.haml b/app/views/notify/access_token_revoked_email.html.haml
index ecd2b3e84b2..780d633f184 100644
--- a/app/views/notify/access_token_revoked_email.html.haml
+++ b/app/views/notify/access_token_revoked_email.html.haml
@@ -2,7 +2,7 @@
= _('Hi %{username}!') % { username: sanitize_name(@user.name) }
%p
= html_escape(_('A personal access token, named %{code_start}%{token_name}%{code_end}, has been revoked.')) % { code_start: '<code>'.html_safe, token_name: @token_name, code_end: '</code>'.html_safe }
-- if @source == 'secret_detection'
+- if @source == :secret_detection
= _('We found your token in a public project and have automatically revoked it to protect your account.')
%p
- pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
diff --git a/app/views/notify/access_token_revoked_email.text.erb b/app/views/notify/access_token_revoked_email.text.erb
index a0623f96488..8ddab63ae27 100644
--- a/app/views/notify/access_token_revoked_email.text.erb
+++ b/app/views/notify/access_token_revoked_email.text.erb
@@ -1,7 +1,7 @@
<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %>
<%= _('A personal access token, named %{token_name}, has been revoked.') % { token_name: @token_name } %>
-<% if @source == 'secret_detection' %>
+<% if @source == :secret_detection %>
<%= _('We found your token in a public project and have automatically revoked it to protect your account.') %>
<% end %>
diff --git a/app/views/notify/github_gists_import_errors_email.html.haml b/app/views/notify/github_gists_import_errors_email.html.haml
new file mode 100644
index 00000000000..07b4cfca77e
--- /dev/null
+++ b/app/views/notify/github_gists_import_errors_email.html.haml
@@ -0,0 +1,19 @@
+- text_style = 'font-size:16px; text-align:center; line-height:30px;'
+
+%p{ style: text_style }
+ = s_('GithubImporter|Your import of GitHub gists into GitLab snippets is complete.')
+
+%p
+ = s_('GithubImporter|GitHub gists that were not imported:')
+
+ %ol
+ - @errors.each do |gist_id, error|
+ %li
+ = s_("GithubImporter|Gist with id %{gist_id} failed due to error: %{error}.") % { gist_id: gist_id, error: error }
+ - if error == Gitlab::GithubGistsImport::Importer::GistImporter::FILE_COUNT_LIMIT_MESSAGE
+ - import_snippets_url = help_page_url('api/import.md', anchor: 'import-github-gists-into-gitlab-snippets')
+ - import_snippets_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: import_snippets_url }
+ = html_escape(s_("GithubImporter|Please follow %{import_snippets_link_start}Import GitHub gists into GitLab snippets%{import_snippets_link_end} for more details.")) % { import_snippets_link_start: import_snippets_link_start, import_snippets_link_end: '</a>'.html_safe }
+
+%p
+ = s_('GithubImporter|GitHub gists with more than 10 files must be manually migrated.')
diff --git a/app/views/notify/github_gists_import_errors_email.text.erb b/app/views/notify/github_gists_import_errors_email.text.erb
new file mode 100644
index 00000000000..2743a658269
--- /dev/null
+++ b/app/views/notify/github_gists_import_errors_email.text.erb
@@ -0,0 +1,12 @@
+<%= s_('GithubImporter|Your import of GitHub gists into GitLab snippets is complete.') %>
+
+<%= s_('GithubImporter|GitHub gists that were not imported:') %>
+<% @errors.each do |gist_id, error| %>
+ - <%= s_("GithubImporter|Gist with id %{gist_id} failed due to error: %{error}.") % { gist_id: gist_id, error: error } %>
+ <% if error == Gitlab::GithubGistsImport::Importer::GistImporter::FILE_COUNT_LIMIT_MESSAGE %>
+ <% import_snippets_url = help_page_url('api/import.md', anchor: 'import-github-gists-into-gitlab-snippets') %>
+ <%= s_("GithubImporter|Please follow %{import_snippets_url} for more details.") % { import_snippets_url: import_snippets_url } %>
+ <% end %>
+<% end %>
+
+<%= s_('GithubImporter|GitHub gists with more than 10 files must be manually migrated.') %>
diff --git a/app/views/notify/service_desk_new_note_email.html.haml b/app/views/notify/service_desk_new_note_email.html.haml
index 02f6b3914c9..969221d00d2 100644
--- a/app/views/notify/service_desk_new_note_email.html.haml
+++ b/app/views/notify/service_desk_new_note_email.html.haml
@@ -2,4 +2,4 @@
.gl-mb-5
= _("%{author_link} wrote:").html_safe % { author_link: link_to(@note.author_name, user_url(@note.author)) }
.md
- = markdown(@note.note, pipeline: :email, author: @note.author, issuable_reference_expansion_enabled: true)
+ = markdown(@note.note, pipeline: :service_desk_email, author: @note.author, issuable_reference_expansion_enabled: true, uploads_as_attachments: @uploads_as_attachments)
diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml
index 0b45869bdf9..ce2fc2098c5 100644
--- a/app/views/profiles/chat_names/_chat_name.html.haml
+++ b/app/views/profiles/chat_names/_chat_name.html.haml
@@ -1,18 +1,20 @@
- integration = chat_name.integration
-- project = integration.project
+- project = integration&.project
%tr
%td
%strong
- - if can?(current_user, :read_project, project)
+ - if project.present? && can?(current_user, :read_project, project)
= link_to project.full_name, project_path(project)
- else
.light= _('Not applicable.')
%td
%strong
- - if can?(current_user, :admin_project, project)
+ - if integration.present? && can?(current_user, :admin_project, project)
= link_to integration.title, edit_project_settings_integration_path(project, integration)
- - else
+ - elsif integration.present?
= integration.title
+ - else
+ .light= _('Not applicable.')
%td
= chat_name.team_domain
%td
diff --git a/app/views/profiles/notifications/_email_settings.html.haml b/app/views/profiles/notifications/_email_settings.html.haml
index c4de33dcd9e..cd7a7ced1d4 100644
--- a/app/views/profiles/notifications/_email_settings.html.haml
+++ b/app/views/profiles/notifications/_email_settings.html.haml
@@ -1,7 +1,6 @@
- form = local_assigns.fetch(:form)
.form-group
- = form.label :notification_email, _('Notification Email'), class: "label-bold"
- = form.select :notification_email, @user.public_verified_emails, { include_blank: _('Use primary email (%{email})') % { email: @user.email }, selected: @user.notification_email }, class: "select2", disabled: local_assigns.fetch(:email_change_disabled, nil)
+ .js-notification-email-listbox-input{ data: { label: _('Notification Email'), name: 'user[notification_email]', emails: @user.public_verified_emails.to_json, empty_value_text: _('Use primary email (%{email})') % { email: @user.email }, value: @user.notification_email, disabled: local_assigns.fetch(:email_change_disabled, nil) } }
.help-block
= local_assigns.fetch(:help_text, nil)
.form-group
diff --git a/app/views/profiles/notifications/_group_settings.html.haml b/app/views/profiles/notifications/_group_settings.html.haml
index 23fce8e04b6..898762ca78a 100644
--- a/app/views/profiles/notifications/_group_settings.html.haml
+++ b/app/views/profiles/notifications/_group_settings.html.haml
@@ -14,4 +14,4 @@
.table-section.section-30
= form_for setting, url: profile_group_notifications_path(group), method: :put, html: { class: 'update-notifications gl-display-flex' } do |f|
- = f.select :notification_email, @user.public_verified_emails, { include_blank: 'Global notification email' }, class: 'select2 js-group-notification-email'
+ .js-notification-email-listbox-input{ data: { name: 'notification_setting[notification_email]', emails: @user.public_verified_emails.to_json, empty_value_text: _('Global notification email') , value: setting.notification_email } }
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 24ef9cf4dec..b10d05efc4f 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -76,12 +76,7 @@
= f.select :layout, layout_choices, {}, class: 'gl-form-select custom-select'
.form-text.text-muted
= s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' }
- .form-group
- = f.label :dashboard, class: 'label-bold' do
- = s_('Preferences|Dashboard')
- = f.select :dashboard, dashboard_choices, {}, class: 'select2'
- .form-text.text-muted
- = s_('Preferences|Choose what content you want to see by default on your dashboard.')
+ .js-listbox-input{ data: { label: s_('Preferences|Dashboard'), description: s_('Preferences|Choose what content you want to see by default on your dashboard.'), name: 'user[dashboard]', items: dashboard_choices.to_json, value: current_user.dashboard } }
= render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific
@@ -130,17 +125,12 @@
= succeed '.' do
= link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'localization'), target: '_blank', rel: 'noopener noreferrer'
.col-lg-8
- .form-group
- = f.label :preferred_language, class: 'label-bold' do
- = _('Language')
- = f.select :preferred_language, language_choices, {}, class: 'select2'
- .form-text.text-muted
- = s_('Preferences|This feature is experimental and translations are not yet complete.')
- %p
- = link_to help_page_url('development/i18n/translation'), class: 'text-nowrap', target: '_blank', rel: 'noopener noreferrer' do
- = _("Help translate GitLab into your language")
- %span{ aria: { label: _('Open new window') } }
- = sprite_icon('external-link')
+ .js-listbox-input{ data: { label: _('Language'), description: s_('Preferences|This feature is experimental and translations are not yet complete.'), name: 'user[preferred_language]', items: language_choices.to_json, value: current_user.preferred_language } }
+ %p.gl-mt-n5
+ = link_to help_page_url('development/i18n/translation'), class: 'text-nowrap', target: '_blank', rel: 'noopener noreferrer' do
+ = _("Help translate GitLab into your language")
+ %span{ aria: { label: _('Open new window') } }
+ = sprite_icon('external-link')
.form-group
= f.label :first_day_of_week, class: 'label-bold' do
= _('First day of the week')
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 712d6fabf82..e4eed63f45a 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -1,8 +1,10 @@
+- @no_breadcrumb_border = true
- show_auto_devops_callout = show_auto_devops_callout?(@project)
- is_project_overview = local_assigns.fetch(:is_project_overview, false)
- ref = local_assigns.fetch(:ref) { current_ref }
- project = local_assigns.fetch(:project) { @project }
- add_page_startup_api_call logs_file_project_ref_path(@project, ref, @path, format: "json", offset: 0)
+- source = visible_fork_source(@project)
- 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")
@@ -16,7 +18,7 @@
= render 'projects/tree/tree_header', tree: @tree, is_project_overview: is_project_overview
- if project.forked? && Feature.enabled?(:fork_divergence_counts, @project.fork_source)
- = render 'projects/fork_info'
+ #js-fork-info{ data: { source_name: source ? source.full_name : '', source_path: source ? project_path(source) : '' } }
- if is_project_overview
.project-buttons.gl-mb-5.js-show-on-project-root{ data: { qa_selector: 'project_buttons' } }
diff --git a/app/views/projects/_fork_info.html.haml b/app/views/projects/_fork_info.html.haml
deleted file mode 100644
index 7fe30214e97..00000000000
--- a/app/views/projects/_fork_info.html.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-.info-well.gl-sm-display-flex.gl-flex-direction-column
- .well-segment.gl-p-5.gl-w-full.gl-display-flex
- .gl-icon.s32.gl-mt-4.gl-mr-4.gl-text-center
- = sprite_icon('fork')
- - source = visible_fork_source(@project)
- - if source
- %div
- #{ s_('ForkedFromProjectPath|Forked from') }
- = link_to source.full_name, project_path(source), data: { qa_selector: 'forked_from_link' }
- .gl-text-secondary
- = fork_divergence_message(::Projects::Forks::DivergenceCounts.new(@project, @ref).counts)
- - else
- .gl-py-4
- = s_('ForkedFromProjectPath|Forked from an inaccessible project')
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index dc426f2f6b7..b9aeed188fa 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -14,6 +14,8 @@
%span.visibility-icon.gl-text-secondary.has-tooltip.gl-ml-2{ data: { container: 'body' }, title: visibility_icon_description(@project) }
= visibility_level_icon(@project.visibility_level, options: { class: 'icon' })
= render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project, additional_classes: 'gl-align-self-center gl-ml-2'
+ - if @project.group
+ = render_if_exists 'shared/tier_badge', source: @project, source_type: 'Project'
.home-panel-metadata.gl-font-sm.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { qa_selector: 'project_id_content' }, itemprop: 'identifier' }
- if can?(current_user, :read_project, @project)
%span.gl-display-inline-block.gl-vertical-align-middle
@@ -60,7 +62,7 @@
#{ s_('ForkedFromProjectPath|Forked from') }
= link_to source.full_name, project_path(source), data: { qa_selector: 'forked_from_link' }
- else
- = s_('ForkedFromProjectPath|Forked from an inaccessible project')
+ = s_('ForkedFromProjectPath|Forked from an inaccessible project.')
= render_if_exists "projects/home_mirror"
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index cc5271a1cd2..412c91544a6 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -7,6 +7,14 @@
%h5.gl-display-flex
= _("Import project from")
= link_to _('History'), import_history_index_path, class: 'gl-link gl-ml-auto gl-font-weight-normal'
+ %div
+ = render Pajamas::AlertComponent.new(variant: :tip,
+ alert_options: { class: 'gl-my-3' },
+ dismissible: false) do |c|
+ = c.body do
+ - docs_link_url = help_page_path('user/group/import/index') + '#migrate-groups-by-direct-transfer-recommended'
+ - docs_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url }
+ = html_escape(_("Importing GitLab projects? Migrating GitLab projects when migrating groups by direct transfer is in Beta. %{link_start}Learn more.%{link_end}")) % { link_start: docs_link, link_end: '</a>'.html_safe }
.import-buttons
- if gitlab_project_import_enabled?
.import_gitlab_project.has-tooltip{ data: { container: 'body', qa_selector: 'gitlab_import_button' } }
diff --git a/app/views/projects/artifacts/index.html.haml b/app/views/projects/artifacts/index.html.haml
index 9cbc149177c..7fa47aed7c6 100644
--- a/app/views/projects/artifacts/index.html.haml
+++ b/app/views/projects/artifacts/index.html.haml
@@ -6,4 +6,6 @@
.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 } }
+ #js-artifact-management{ data: { "project-path": @project.full_path,
+ "can-destroy-artifacts" => can?(current_user, :destroy_artifacts, @project).to_s,
+ "artifacts-management-feedback-image-path": image_path('illustrations/chat-bubble-sm.svg') } }
diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml
index 4fe68c1ce1a..c1f4633f69f 100644
--- a/app/views/projects/blob/_template_selectors.html.haml
+++ b/app/views/projects/blob/_template_selectors.html.haml
@@ -1,8 +1,5 @@
.template-selectors-menu.gl-pl-3
.template-selector-dropdowns-wrap
- .template-type-selector.js-template-type-selector-wrap.hidden
- - toggle_text = should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : 'Select a template type'
- = dropdown_tag(_(toggle_text), options: { toggle_class: 'js-template-type-selector', dropdown_class: 'dropdown-menu-selectable', data: { qa_selector: 'template_type_dropdown' } })
.license-selector.js-license-selector-wrap.js-template-selector-wrap.hidden
= dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-license-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name, qa_selector: 'license_dropdown' } })
.gitignore-selector.js-gitignore-selector-wrap.js-template-selector-wrap.hidden
diff --git a/app/views/projects/branch_defaults/_branch_names_fields.html.haml b/app/views/projects/branch_defaults/_branch_names_fields.html.haml
index 65f975fbd9e..393b19e6c5a 100644
--- a/app/views/projects/branch_defaults/_branch_names_fields.html.haml
+++ b/app/views/projects/branch_defaults/_branch_names_fields.html.haml
@@ -10,5 +10,5 @@
%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')
+ - branch_name_help_link = help_page_path('user/project/merge_requests/creating_merge_requests.md', anchor: 'from-an-issue')
= link_to _('What variables can I use?'), branch_name_help_link, target: "_blank"
diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml
index 97186149a9d..6d05f1dc955 100644
--- a/app/views/projects/buttons/_fork.html.haml
+++ b/app/views/projects/buttons/_fork.html.haml
@@ -11,7 +11,7 @@
- button_class = 'disabled' if disabled_tooltip
%span.btn-group{ class: ('has-tooltip' if disabled_tooltip), title: disabled_tooltip }
- = link_to new_project_fork_path(@project), class: "gl-button btn btn-default fork-btn #{button_class}" do
+ = link_to new_project_fork_path(@project), class: "gl-button btn btn-default fork-btn #{button_class}", data: { qa_selector: 'fork_button' } do
= sprite_icon('fork', css_class: 'icon')
%span= s_('ProjectOverview|Fork')
= link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Forks'), s_('ProjectOverview|Forks'), @project.forks_count), class: "gl-button btn btn-default count has-tooltip fork-count #{count_class}" do
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index b48369322e4..ecdd43a54f9 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -87,7 +87,7 @@
%td
- if job.duration
%p.duration
- = custom_icon("icon_timer")
+ = sprite_icon("timer")
= duration_in_numbers(job.duration)
- if job.finished_at
diff --git a/app/views/projects/commit/_multiple_signatures_signature_badge.html.haml b/app/views/projects/commit/_multiple_signatures_signature_badge.html.haml
index 74515438af2..2568a69cc2c 100644
--- a/app/views/projects/commit/_multiple_signatures_signature_badge.html.haml
+++ b/app/views/projects/commit/_multiple_signatures_signature_badge.html.haml
@@ -1,6 +1,5 @@
-- title = capture do
- = html_escape(_('This commit was signed with %{strong_open}multiple%{strong_close} signatures.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
-
-- locals = { signature: signature, title: title, label: _('Unverified'), css_class: 'invalid', icon: 'status_notfound_borderless' }
+- title = _('Multiple signatures')
+- description = _('This commit was signed with multiple signatures.')
+- locals = { signature: signature, title: title, description: description, label: _('Unverified'), css_class: 'invalid' }
= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_other_user_signature_badge.html.haml b/app/views/projects/commit/_other_user_signature_badge.html.haml
index bb843bee7c9..ffc4b25dc21 100644
--- a/app/views/projects/commit/_other_user_signature_badge.html.haml
+++ b/app/views/projects/commit/_other_user_signature_badge.html.haml
@@ -1,6 +1,5 @@
-- title = capture do
- = _("This commit was signed with a different user's verified signature.")
-
-- locals = { signature: signature, title: title, label: _('Unverified'), css_class: 'invalid', icon: 'status_notfound_borderless', show_user: true }
+- title = _("Different user's signature")
+- description = _("This commit was signed with a different user's verified signature.")
+- locals = { signature: signature, title: title, description: description, label: _('Unverified'), css_class: 'invalid' }
= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml
index 629d3cfaf74..61fdf6fc87a 100644
--- a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml
+++ b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml
@@ -1,6 +1,5 @@
-- title = capture do
- = html_escape(_('This commit was signed with a verified signature, but the committer email is not associated with the GPG Key.'))
-
-- locals = { signature: signature, title: title, label: _('Unverified'), css_class: ['invalid'], icon: 'status_notfound_borderless', show_user: true }
+- title = _('GPG key mismatch')
+- description = _('This commit was signed with a verified signature, but the committer email is not associated with the GPG Key.')
+- locals = { signature: signature, title: title, description: description, label: _('Unverified'), css_class: ['invalid'] }
= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml
index ad6b524c01b..41ba581b9d9 100644
--- a/app/views/projects/commit/_signature_badge.html.haml
+++ b/app/views/projects/commit/_signature_badge.html.haml
@@ -1,39 +1,35 @@
- signature = local_assigns.fetch(:signature)
- title = local_assigns.fetch(:title)
+- description = local_assigns.fetch(:description, nil)
- label = local_assigns.fetch(:label)
- css_class = local_assigns.fetch(:css_class)
-- icon = local_assigns.fetch(:icon)
-- show_user = local_assigns.fetch(:show_user, false)
- css_classes = commit_signature_badge_classes(css_class)
- title = capture do
.gpg-popover-status
- .gpg-popover-icon{ class: css_class }
- = sprite_icon(icon)
%div
- = title
+ %strong
+ = title
-- content = capture do
- - if show_user
- .clearfix
- - uri_signature_badge_user = "projects/commit/#{'x509/' if signature.x509?}signature_badge_user"
- = render partial: "#{uri_signature_badge_user}", locals: { signature: signature }
+ %p.gl-my-3
+ = description
+- content = capture do
- if signature.x509?
= render partial: "projects/commit/x509/certificate_details", locals: { signature: signature }
= link_to(_('Learn more about X.509 signed commits'), help_page_path('user/project/repository/x509_signed_commits/index.md'), class: 'gl-link gl-display-block')
- - elsif ::Feature.enabled?(:ssh_commit_signatures, signature.project) && signature.ssh?
+ - elsif signature.ssh?
= _('SSH key fingerprint:')
- %span.gl-font-monospace= signature.key&.fingerprint_sha256 || _('Unknown')
+ %span.gl-font-monospace= signature.key_fingerprint_sha256 || _('Unknown')
- = link_to(_('Learn about signing commits with SSH keys.'), help_page_path('user/project/repository/ssh_signed_commits/index.md'), class: 'gl-link gl-display-block')
+ = link_to(_('Learn about signing commits with SSH keys.'), help_page_path('user/project/repository/ssh_signed_commits/index.md'), class: 'gl-link gl-display-block gl-mt-3')
- else
= _('GPG Key ID:')
%span.gl-font-monospace= signature.gpg_key_primary_keyid
- = link_to(_('Learn more about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gl-link gl-display-block')
+ = link_to(_('Learn about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gl-link gl-display-block gl-mt-3')
%a{ role: 'button', tabindex: 0, class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } }
= label
diff --git a/app/views/projects/commit/_unverified_signature_badge.html.haml b/app/views/projects/commit/_unverified_signature_badge.html.haml
index 0ce8e06382b..0eae8d5564d 100644
--- a/app/views/projects/commit/_unverified_signature_badge.html.haml
+++ b/app/views/projects/commit/_unverified_signature_badge.html.haml
@@ -1,6 +1,5 @@
-- title = capture do
- = html_escape(_('This commit was signed with an %{strong_open}unverified%{strong_close} signature.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
-
-- locals = { signature: signature, title: title, label: _('Unverified'), css_class: 'invalid', icon: 'status_notfound_borderless' }
+- title = _('Unverified signature')
+- description = _('This commit was signed with an unverified signature.')
+- locals = { signature: signature, title: title, description: description, label: _('Unverified'), css_class: 'invalid' }
= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_verified_signature_badge.html.haml b/app/views/projects/commit/_verified_signature_badge.html.haml
index 357ad467539..417d816c711 100644
--- a/app/views/projects/commit/_verified_signature_badge.html.haml
+++ b/app/views/projects/commit/_verified_signature_badge.html.haml
@@ -1,6 +1,5 @@
-- title = capture do
- = html_escape(_('This commit was signed with a %{strong_open}verified%{strong_close} signature and the committer email is verified to belong to the same user.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
-
-- locals = { signature: signature, title: title, label: _('Verified'), css_class: 'valid', icon: 'status_success_borderless', show_user: true }
+- title = _('Verified commit')
+- description = _('This commit was signed with a verified signature and the committer email was verified to belong to the same user.')
+- locals = { signature: signature, title: title, description: description, label: _('Verified'), css_class: 'valid' }
= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index c129d978e7e..8f802792e6a 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -10,7 +10,7 @@
.nav-block
.tree-ref-container
.tree-ref-holder
- #js-project-commits-ref-switcher{ data: { "project-id" => @project.id, "ref" => @ref, "commits_path": project_commits_path(@project) } }
+ #js-project-commits-ref-switcher{ data: { "project-id" => @project.id, "ref" => @ref, "commits_path": project_commits_path(@project), "ref_type": @ref_type.to_s } }
%ul.breadcrumb.repo-breadcrumb
= commits_breadcrumbs
diff --git a/app/views/projects/feature_flags/new.html.haml b/app/views/projects/feature_flags/new.html.haml
index 9fef9864475..c91487ad198 100644
--- a/app/views/projects/feature_flags/new.html.haml
+++ b/app/views/projects/feature_flags/new.html.haml
@@ -10,5 +10,5 @@
user_callout_id: Users::CalloutsHelper::FEATURE_FLAGS_NEW_VERSION,
show_user_callout: show_feature_flags_new_version?.to_s,
strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'),
- environments_scope_docs_path: help_page_path('ci/environments/index.md', anchor: 'scope-environments-with-specs'),
+ environments_scope_docs_path: help_page_path('ci/environments/index.md', anchor: 'limit-the-environment-scope-of-a-cicd-variable'),
project_id: @project.id } }
diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml
index a9913fe3d5e..e9c6b3fcd22 100644
--- a/app/views/projects/forks/new.html.haml
+++ b/app/views/projects/forks/new.html.haml
@@ -5,6 +5,7 @@
new_group_path: new_group_path,
project_full_path: @project.full_path,
visibility_help_path: help_page_path("user/public_access"),
+ cancel_path: project_path(@project),
project_id: @project.id,
project_name: @project.name,
project_path: @project.path,
diff --git a/app/views/projects/imports/new.html.haml b/app/views/projects/imports/new.html.haml
index 306f24d717b..efb364bd013 100644
--- a/app/views/projects/imports/new.html.haml
+++ b/app/views/projects/imports/new.html.haml
@@ -16,4 +16,4 @@
= render 'shared/import_form', f: f
.form-actions
- = f.submit 'Start import', class: 'gl-button btn btn-confirm', data: { disable_with: false }
+ = f.submit 'Start import', pajamas_button: true, data: { disable_with: false }
diff --git a/app/views/projects/issues/_work_item_links.html.haml b/app/views/projects/issues/_work_item_links.html.haml
index 72f9ec2ff16..3deceacec8d 100644
--- a/app/views/projects/issues/_work_item_links.html.haml
+++ b/app/views/projects/issues/_work_item_links.html.haml
@@ -1 +1,5 @@
-.js-work-item-links-root{ data: { issuable_id: @issue.id, iid: @issue.iid, 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),
+ register_path: new_user_registration_path(redirect_to_referer: 'yes'),
+ sign_in_path: new_session_path(:user, redirect_to_referer: 'yes') } }
diff --git a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
index fb950611f81..0123c5efd3d 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
@@ -47,7 +47,6 @@
- if moved_mr_sidebar_enabled?
%li.gl-dropdown-divider
%hr.dropdown-divider
- %li.gl-dropdown-item
- = link_to new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)), class: 'dropdown-item' do
- .gl-dropdown-item-text-wrapper
- = _('Report abuse to administrator')
+ #js-report-abuse-dropdown-item{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: @merge_request.author.id, reported_from_url: merge_request_url(@merge_request) } }
+
+#js-report-abuse-drawer
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index 71f8e4c32f5..b96d869e9d7 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -12,6 +12,7 @@
.issuable-main-info
.merge-request-title.title
%span.merge-request-title-text.js-onboarding-mr-item
+ = hidden_merge_request_icon(merge_request)
= link_to merge_request.title, merge_request_path(merge_request), class: 'js-prefetch-document'
- if merge_request.tasks?
%span.task-status.d-none.d-sm-inline-block
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index a73d2aa5cc4..9d25603994a 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -16,7 +16,7 @@
.detail-page-header.border-bottom-0.pt-0.pb-0.gl-display-block{ class: "gl-md-display-flex! #{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" }
.detail-page-header-body
.issuable-meta.gl-display-flex
- #js-issuable-header-warnings
+ #js-issuable-header-warnings{ data: { hidden: @merge_request.hidden?.to_s } }
%h1.title.page-title.gl-font-size-h-display.gl-my-0.gl-display-inline-block{ data: { qa_selector: 'title_content' } }
= markdown_field(@merge_request, :title)
diff --git a/app/views/projects/merge_requests/_page.html.haml b/app/views/projects/merge_requests/_page.html.haml
index 9d79352659c..47eb3e016d3 100644
--- a/app/views/projects/merge_requests/_page.html.haml
+++ b/app/views/projects/merge_requests/_page.html.haml
@@ -69,7 +69,7 @@
= render "projects/merge_requests/awards_block"
= render "projects/merge_requests/widget"
- if mr_action === "show"
- - add_page_startup_api_call Feature.enabled?(:paginated_mr_discussions, @project) ? discussions_path(@merge_request, per_page: 20) : discussions_path(@merge_request)
+ - add_page_startup_api_call discussions_path(@merge_request, per_page: 20)
- add_page_startup_api_call widget_project_json_merge_request_path(@project, @merge_request, format: :json)
- add_page_startup_api_call cached_widget_project_json_merge_request_path(@project, @merge_request, format: :json)
#js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request).to_json,
diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml
index 8ff7fe6da71..95ef856daba 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -3,6 +3,7 @@
- page_title @milestone.title, _('Milestones')
- page_description @milestone.description_html
- add_page_specific_style 'page_bundles/milestone'
+- add_page_specific_style 'page_bundles/issuable'
- add_page_startup_api_call milestone_tab_path(@milestone, 'issues', show_project_name: false)
diff --git a/app/views/projects/ml/experiments/show.html.haml b/app/views/projects/ml/experiments/show.html.haml
index 2c350439762..143981eebe6 100644
--- a/app/views/projects/ml/experiments/show.html.haml
+++ b/app/views/projects/ml/experiments/show.html.haml
@@ -11,4 +11,6 @@
#js-show-ml-experiment{ data: {
candidates: items,
metrics: metrics,
- params: params } }
+ params: params,
+ pagination: @pagination.to_json
+} }
diff --git a/app/views/projects/pages/new.html.haml b/app/views/projects/pages/new.html.haml
index f1f3510d0f8..b9d2af9cf19 100644
--- a/app/views/projects/pages/new.html.haml
+++ b/app/views/projects/pages/new.html.haml
@@ -1,8 +1,14 @@
-%section.js-search-settings-section
- - if Feature.enabled?(:use_pipeline_wizard_for_pages, @project.group)
- #js-pages{ data: @pipeline_wizard_data }
+- if Feature.enabled?(:show_pages_in_deployments_menu, current_user, type: :experiment)
+ - @breadcrumb_link = project_pages_path(@project)
+ - breadcrumb_title s_('GitLabPages|Pages')
+ - page_title s_('GitLabPages|Pages')
+- else
+ %section.js-search-settings-section
- - else
- = render 'header'
+- if Feature.enabled?(:use_pipeline_wizard_for_pages, @project.group)
+ #js-pages{ data: @pipeline_wizard_data }
- = render 'use'
+- else
+ = render 'header'
+
+ = render 'use'
diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml
index cb7cd631859..ab86d505f0f 100644
--- a/app/views/projects/pipeline_schedules/index.html.haml
+++ b/app/views/projects/pipeline_schedules/index.html.haml
@@ -6,7 +6,7 @@
#pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipelines/schedules'), illustration_url: image_path('illustrations/pipeline_schedule_callout.svg') } }
- if Feature.enabled?(:pipeline_schedules_vue, @project)
- #pipeline-schedules-app{ data: { full_path: @project.full_path } }
+ #pipeline-schedules-app{ data: { full_path: @project.full_path, pipelines_path: project_pipelines_path(@project) } }
- else
.top-area
- schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) }
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 1a079324a0f..8f7f0a15e69 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -1,4 +1,4 @@
-- if Feature.enabled?(:pipeline_name, @pipeline.project) && @pipeline.name
+- if @pipeline.name
.gl-border-t.gl-p-5.gl-px-0
%h3.gl-m-0.gl-text-body
= @pipeline.name
@@ -53,7 +53,7 @@
.well-segment{ 'data-testid': 'commit-row' }
.icon-container.commit-icon
= sprite_icon('commit', css_class: 'gl-top-0!')
- - if Feature.enabled?(:pipeline_name, @pipeline.project) && @pipeline.name
+ - if @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"
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index 1b35de85145..b27f5a0e5ed 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -43,7 +43,10 @@
= _("Runners are processes that pick up and execute CI/CD jobs for GitLab.")
= link_to s_('What is GitLab Runner?'), 'https://docs.gitlab.com/runner/', target: '_blank', rel: 'noopener noreferrer'
.settings-content
- = render 'projects/runners/settings'
+ - if Feature.enabled?(:project_runners_vue_ui, @project)
+ #js-project-runners{ data: { project_full_path: @project.full_path } }
+ - else
+ = render 'projects/runners/settings'
- if Gitlab::CurrentSettings.current_application_settings.keep_latest_artifact?
%section.settings.no-animate#js-artifacts-settings{ class: ('expanded' if expanded) }
diff --git a/app/views/projects/settings/repository/_protected_branches.html.haml b/app/views/projects/settings/repository/_protected_branches.html.haml
index d2356b5df09..340883ba853 100644
--- a/app/views/projects/settings/repository/_protected_branches.html.haml
+++ b/app/views/projects/settings/repository/_protected_branches.html.haml
@@ -1,2 +1,2 @@
-= render "protected_branches/index"
+= render "protected_branches/index", protected_branch_entity: protected_branch_entity
= render "projects/protected_tags/index"
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index 306ce47cee7..953bfcf71ab 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -13,7 +13,7 @@
-# The shared parts of the views can be found in the `shared` directory.
-# Those are used throughout the actual views. These `shared` views are then
-# reused in EE.
-= render "projects/settings/repository/protected_branches"
+= render "projects/settings/repository/protected_branches", protected_branch_entity: @project
= render "shared/deploy_tokens/index", group_or_project: @project, description: deploy_token_description
= render @deploy_keys
= render "projects/cleanup/show"
diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml
index a7f29b5cbf9..de127d15351 100644
--- a/app/views/projects/triggers/_index.html.haml
+++ b/app/views/projects/triggers/_index.html.haml
@@ -6,32 +6,7 @@
- c.body do
= render 'projects/triggers/form', btn_text: _('Add trigger')
.gl-mb-5
- - if Feature.enabled?(:ci_pipeline_triggers_settings_vue_ui, @project)
- #js-ci-pipeline-triggers-list.triggers-list{ data: { triggers: @triggers_json } }
- - else
- - if @triggers.any?
- .table-responsive.triggers-list
- %table.table
- %thead
- %th
- %strong
- = _('Token')
- %th
- %strong
- = _('Description')
- %th
- %strong
- = _('Owner')
- %th
- %strong
- = _('Last used')
- %th
- = render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger
- - else
- = render Pajamas::AlertComponent.new(variant: :warning, show_icon: false, dismissible: false,
- alert_options: { data: { testid: 'no_triggers_content' }}) do |c|
- = c.body do
- = _('No triggers exist yet. Use the form above to create one.')
+ #js-ci-pipeline-triggers-list.triggers-list{ data: { triggers: @triggers_json } }
- c.footer do
%p
= _("These examples show how to trigger this project's pipeline for a branch or tag.")
diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml
deleted file mode 100644
index bce7dc8a94b..00000000000
--- a/app/views/projects/triggers/_trigger.html.haml
+++ /dev/null
@@ -1,37 +0,0 @@
-%tr
- %td
- - if trigger.has_token_exposed?
- %span= trigger.token
- = clipboard_button(text: trigger.token, title: _("Copy trigger token"), testid: 'clipboard-btn')
- - else
- %span= trigger.short_token
-
- .gl-display-inline-block.gl-ml-3
- - unless trigger.can_access_project?
- = gl_badge_tag s_('Trigger|invalid'), { variant: :danger }, { title: s_('Trigger|Trigger user has insufficient permissions to project'), data: { toggle: 'tooltip', container: 'body' } }
-
- %td
- - if trigger.description? && trigger.description.length > 15
- %span.has-tooltip{ title: trigger.description }= truncate(trigger.description, length: 15)
- - else
- = trigger.description
-
- %td
- - if trigger.owner
- .trigger-owner.sr-only= trigger.owner.name
- = user_avatar(user: trigger.owner, size: 20)
-
- %td
- - if trigger.last_used
- = time_ago_with_tooltip trigger.last_used
- - else
- Never
-
- %td.text-right.gl-white-space-nowrap
- - revoke_trigger_confirmation = "By revoking a trigger you will break any processes making use of it. Are you sure?"
- - if can?(current_user, :admin_trigger, trigger)
- = link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "gl-button btn btn-default btn-icon" do
- = sprite_icon('pencil')
- - if can?(current_user, :manage_trigger, trigger)
- = link_to project_trigger_path(@project, trigger), aria: { label: _('Revoke') }, data: { confirm: revoke_trigger_confirmation, testid: 'trigger_revoke_button', confirm_btn_variant: "danger" }, method: :delete, title: "Revoke", class: "gl-button btn btn-default btn-icon btn-trigger-revoke gl-ml-3" do
- = sprite_icon('remove')
diff --git a/app/views/protected_branches/_branches_list.html.haml b/app/views/protected_branches/_branches_list.html.haml
index 82eac348f16..2b0160f98e7 100644
--- a/app/views/protected_branches/_branches_list.html.haml
+++ b/app/views/protected_branches/_branches_list.html.haml
@@ -1,4 +1,4 @@
-- can_admin_project = can?(current_user, :admin_project, @project)
+- can_admin_entity = protected_branch_can_admin_entity?(protected_branch_entity)
-= render layout: 'protected_branches/shared/branches_list', locals: { can_admin_project: can_admin_project } do
- = render partial: 'protected_branches/protected_branch', collection: @protected_branches
+= render layout: 'protected_branches/shared/branches_list', locals: { can_admin_entity: can_admin_entity, protected_branch_entity: protected_branch_entity } do
+ = render partial: 'protected_branches/protected_branch', collection: @protected_branches, locals: { protected_branch_entity: protected_branch_entity }
diff --git a/app/views/protected_branches/_create_protected_branch.html.haml b/app/views/protected_branches/_create_protected_branch.html.haml
index 22a49ba9c7e..b4765ab49c2 100644
--- a/app/views/protected_branches/_create_protected_branch.html.haml
+++ b/app/views/protected_branches/_create_protected_branch.html.haml
@@ -11,4 +11,4 @@
dropdown_class: 'dropdown-menu-selectable capitalize-header', dropdown_qa_selector: 'allowed_to_push_dropdown_content' , dropdown_testid: 'allowed-to-push-dropdown',
data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes', qa_selector: 'allowed_to_push_dropdown' }})
-= render 'protected_branches/shared/create_protected_branch'
+= render 'protected_branches/shared/create_protected_branch', protected_branch_entity: protected_branch_entity
diff --git a/app/views/protected_branches/_index.html.haml b/app/views/protected_branches/_index.html.haml
index 4beca4845b8..84a221555ab 100644
--- a/app/views/protected_branches/_index.html.haml
+++ b/app/views/protected_branches/_index.html.haml
@@ -1,7 +1,7 @@
- content_for :create_protected_branch do
- = render 'protected_branches/create_protected_branch'
+ = render 'protected_branches/create_protected_branch', protected_branch_entity: protected_branch_entity
- content_for :branches_list do
- = render "protected_branches/branches_list"
+ = render "protected_branches/branches_list", protected_branch_entity: protected_branch_entity
-= render 'protected_branches/shared/index'
+= render 'protected_branches/shared/index', protected_branch_entity: protected_branch_entity
diff --git a/app/views/protected_branches/_protected_branch.html.haml b/app/views/protected_branches/_protected_branch.html.haml
index 423d7f23eb5..acd62968b09 100644
--- a/app/views/protected_branches/_protected_branch.html.haml
+++ b/app/views/protected_branches/_protected_branch.html.haml
@@ -1,2 +1,2 @@
-= render layout: 'protected_branches/shared/protected_branch', locals: { protected_branch: protected_branch } do
+= render layout: 'protected_branches/shared/protected_branch', locals: { protected_branch: protected_branch, protected_branch_entity: protected_branch_entity } do
= render_if_exists 'protected_branches/update_protected_branch', protected_branch: protected_branch
diff --git a/app/views/protected_branches/shared/_branches_list.html.haml b/app/views/protected_branches/shared/_branches_list.html.haml
index d041f9c5b48..c35895e000c 100644
--- a/app/views/protected_branches/shared/_branches_list.html.haml
+++ b/app/views/protected_branches/shared/_branches_list.html.haml
@@ -13,7 +13,7 @@
%col{ width: "20%" }
%col{ width: "10%" }
%col{ width: "10%" }
- - if can_admin_project
+ - if can_admin_entity
%col
%thead
%tr
@@ -28,9 +28,9 @@
%span.has-tooltip{ data: { container: 'body' }, title: s_('ProtectedBranch|Allow all users with push access to force push.'), 'aria-hidden': 'true' }
= sprite_icon('question', size: 16, css_class: 'gl-text-gray-500')
- = render_if_exists 'protected_branches/ee/code_owner_approval_table_head'
+ = render_if_exists 'protected_branches/ee/code_owner_approval_table_head', protected_branch_entity: protected_branch_entity
- - if can_admin_project
+ - if can_admin_entity
%th
%tbody
= yield
diff --git a/app/views/protected_branches/shared/_create_protected_branch.html.haml b/app/views/protected_branches/shared/_create_protected_branch.html.haml
index 6b4a143df69..315daa5e029 100644
--- a/app/views/protected_branches/shared/_create_protected_branch.html.haml
+++ b/app/views/protected_branches/shared/_create_protected_branch.html.haml
@@ -1,4 +1,4 @@
-= gitlab_ui_form_for [@project, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f|
+= gitlab_ui_form_for [protected_branch_entity, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f|
%input{ type: 'hidden', name: 'update_section', value: 'js-protected-branches-settings' }
= render Pajamas::CardComponent.new(card_options: { class: "gl-mb-5" }) do |c|
- c.header do
@@ -8,11 +8,18 @@
.form-group.row
= f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-sm-12'
.col-sm-12
- = render partial: "protected_branches/shared/dropdown", locals: { f: f, toggle_classes: 'gl-w-full! gl-form-input-lg' }
+ - if protected_branch_entity.is_a?(Group)
+ = f.text_field :name, placeholder: 'prod*', class: 'form-control gl-w-full! gl-form-input-lg'
+ - else
+ = render partial: "protected_branches/shared/dropdown", locals: { f: f, toggle_classes: 'gl-w-full! gl-form-input-lg' }
.form-text.text-muted
- wildcards_url = help_page_url('user/project/protected_branches', anchor: 'configure-multiple-protected-branches-by-using-a-wildcard')
- wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url }
- = (s_("ProtectedBranch|%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported.") % { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>', code_tag_start: '<code>', code_tag_end: '</code>' }).html_safe
+ - placeholders = { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>', code_tag_start: '<code>', code_tag_end: '</code>' }
+ - if protected_branch_entity.is_a?(Group)
+ = (s_("ProtectedBranch|Only %{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported.") % placeholders).html_safe
+ - else
+ = (s_("ProtectedBranch|%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported.") % placeholders).html_safe
.form-group.row
= f.label :merge_access_levels_attributes, s_("ProtectedBranch|Allowed to merge:"), class: 'col-sm-12'
.col-sm-12
@@ -30,6 +37,6 @@
- force_push_docs_url = help_page_url('topics/git/git_rebase', anchor: 'force-push')
- force_push_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: force_push_docs_url }
= (s_("ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}.") % { tag_start: force_push_link_start, tag_end: '</a>' }).html_safe
- = render_if_exists 'protected_branches/ee/code_owner_approval_form', f: f
+ = render_if_exists 'protected_branches/ee/code_owner_approval_form', f: f, protected_branch_entity: protected_branch_entity
- c.footer do
= f.submit s_('ProtectedBranch|Protect'), disabled: true, data: { qa_selector: 'protect_button' }, pajamas_button: true
diff --git a/app/views/protected_branches/shared/_index.html.haml b/app/views/protected_branches/shared/_index.html.haml
index c204508d355..d0e21e38429 100644
--- a/app/views/protected_branches/shared/_index.html.haml
+++ b/app/views/protected_branches/shared/_index.html.haml
@@ -1,3 +1,4 @@
+- can_admin_entity = protected_branch_can_admin_entity?(protected_branch_entity)
- expanded = expanded_by_default?
%section.settings.no-animate#js-protected-branches-settings{ class: ('expanded' if expanded), data: { qa_selector: 'protected_branches_settings_content' } }
@@ -14,7 +15,7 @@
= s_("ProtectedBranch|By default, protected branches restrict who can modify the branch.")
= link_to s_("ProtectedBranch|Learn more."), help_page_path("user/project/protected_branches", anchor: "who-can-modify-a-protected-branch")
- - if can? current_user, :admin_project, @project
+ - if can_admin_entity
= content_for :create_protected_branch
= content_for :branches_list
diff --git a/app/views/protected_branches/shared/_protected_branch.html.haml b/app/views/protected_branches/shared/_protected_branch.html.haml
index 5dea85aaa41..b4fd7a24b41 100644
--- a/app/views/protected_branches/shared/_protected_branch.html.haml
+++ b/app/views/protected_branches/shared/_protected_branch.html.haml
@@ -1,23 +1,25 @@
-- can_admin_project = can?(current_user, :admin_project, @project)
+- can_admin_entity = protected_branch_can_admin_entity?(protected_branch_entity)
+- url = protected_branch_path_by_entity(protected_branch, protected_branch_entity)
-%tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch), testid: 'protected-branch' } }
+%tr.js-protected-branch-edit-form{ data: { url: url, testid: 'protected-branch' } }
%td
%span.ref-name= protected_branch.name
- - if @project.root_ref?(protected_branch.name)
+ - if protected_branch_entity.is_a?(Project) && protected_branch_entity.root_ref?(protected_branch.name)
= gl_badge_tag s_('ProtectedBranch|default'), variant: :info
- %div
- - if protected_branch.wildcard?
- - matching_branches = protected_branch.matching(repository.branch_names)
- = link_to pluralize(matching_branches.count, "matching branch"), namespace_project_protected_branch_path(@project.namespace, @project, protected_branch)
- - elsif !protected_branch.commit
- %span.text-muted Branch was deleted.
+ - if protected_branch_entity.is_a?(Project)
+ %div
+ - if protected_branch.wildcard?
+ - matching_branches = protected_branch.matching(repository.branch_names)
+ = link_to pluralize(matching_branches.count, "matching branch"), namespace_project_protected_branch_path(@project.namespace, @project, protected_branch)
+ - elsif !protected_branch.commit
+ %span.text-muted= s_('ProtectedBranch|Branch does not exist.')
= yield
- = render_if_exists 'protected_branches/ee/code_owner_approval_table', protected_branch: protected_branch
+ = render_if_exists 'protected_branches/ee/code_owner_approval_table', protected_branch: protected_branch, protected_branch_entity: protected_branch_entity
- - if can_admin_project
+ - if can_admin_entity
%td
- = link_to s_('ProtectedBranch|Unprotect'), [@project, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], aria: { label: s_('ProtectedBranch|Unprotect branch') }, data: { confirm: s_('ProtectedBranch|Branch will be writable for developers. Are you sure?'), confirm_btn_variant: 'danger' }, method: :delete, class: "btn gl-button btn-danger btn-sm"
+ = link_to s_('ProtectedBranch|Unprotect'), [protected_branch_entity, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], aria: { label: s_('ProtectedBranch|Unprotect branch') }, data: { confirm: s_('ProtectedBranch|Branch will be writable for developers. Are you sure?'), confirm_btn_variant: 'danger' }, method: :delete, class: "btn gl-button btn-danger btn-sm"
diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb
index c5403caeafa..9abb759389d 100644
--- a/app/views/pwa/manifest.json.erb
+++ b/app/views/pwa/manifest.json.erb
@@ -1,6 +1,6 @@
{
"name": "<%= Appearance.current&.title.presence || _('GitLab') %>",
- "short_name": "<%= Appearance.current&.short_title.presence || _('GitLab') %>",
+ "short_name": "<%= appearance_short_name %>",
"description": "<%= Appearance.current&.description.presence || _("The complete DevOps platform. One application with endless possibilities. Organizations rely on GitLab’s source code management, CI/CD, security, and more to deliver software rapidly.") %>",
"start_url": "<%= explore_projects_path %>",
"scope": "<%= root_path %>",
diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml
index 3e483fe8cd2..74a5d5fb425 100644
--- a/app/views/search/_category.html.haml
+++ b/app/views/search/_category.html.haml
@@ -23,7 +23,7 @@
= search_filter_link 'milestones', _("Milestones")
= users
- - elsif @search_service.show_snippets?
+ - elsif @search_service_presenter.show_snippets?
= search_filter_link 'snippet_titles', _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil }
- else
= search_filter_link 'projects', _("Projects"), data: { qa_selector: 'projects_tab' }
diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml
index 027ae6bf77c..5c952042663 100644
--- a/app/views/search/_results.html.haml
+++ b/app/views/search/_results.html.haml
@@ -3,16 +3,16 @@
= render_if_exists 'shared/promotions/promote_advanced_search'
- 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 } }
+ #js-search-sidebar{ class: search_bar_classes, data: { navigation_json: search_navigation_json } }
.gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden
- = render partial: 'search/results_status', locals: { search_service: @search_service } unless @search_objects.to_a.empty?
+ = render partial: 'search/results_status' 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?
+ = render partial: 'search/results_status' unless @search_objects.to_a.empty?
.results.gl-md-display-flex.gl-mt-3
- if %w[issues merge_requests].include?(@scope)
- #js-search-sidebar{ class: search_bar_classes, data: { navigation: search_navigation_json } }
+ #js-search-sidebar{ class: search_bar_classes, data: { navigation_json: search_navigation_json } }
.gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden
= render partial: 'search/results_list'
diff --git a/app/views/search/_results_list.html.haml b/app/views/search/_results_list.html.haml
index cf910402ad4..195f0f3ad8a 100644
--- a/app/views/search/_results_list.html.haml
+++ b/app/views/search/_results_list.html.haml
@@ -7,7 +7,7 @@
%ul.content-list.commit-list
= render partial: "search/results/commit", collection: @search_objects
- else
- .search-results
+ .search-results.js-search-results
- if @scope == 'projects'
.term
= render 'shared/projects/list', projects: @search_objects, pipeline_status: false
diff --git a/app/views/search/_results_status.html.haml b/app/views/search/_results_status.html.haml
index adea6b598f7..3cd100db8b7 100644
--- a/app/views/search/_results_status.html.haml
+++ b/app/views/search/_results_status.html.haml
@@ -1,8 +1,6 @@
-- search_service = local_assigns.fetch(:search_service)
-
-- return unless search_service.show_results_status?
+- return unless @search_service_presenter.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'
- else
- = render partial: 'search/results_status_horiz_nav', locals: { search_service: search_service }
+ = render partial: 'search/results_status_horiz_nav'
diff --git a/app/views/search/_results_status_horiz_nav.html.haml b/app/views/search/_results_status_horiz_nav.html.haml
index fe6ee0f12ec..c0778b70c04 100644
--- a/app/views/search/_results_status_horiz_nav.html.haml
+++ b/app/views/search/_results_status_horiz_nav.html.haml
@@ -1,22 +1,22 @@
.search-results-status
.row-content-block.gl-display-flex
.gl-md-display-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1
- - unless search_service.without_count?
- = search_entries_info(search_service.search_objects, search_service.scope, params[:search])
- - unless search_service.show_snippets?
- - if search_service.project
- - link_to_project = link_to(search_service.project.full_name, search_service.project, class: 'ml-md-1')
- - if search_service.scope == 'blobs'
+ - unless @search_service_presenter.without_count?
+ = search_entries_info(@search_objects, @scope, @search_term)
+ - unless @search_service_presenter.show_snippets?
+ - if @project
+ - link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1')
+ - if @scope == 'blobs'
= _("in")
.mx-md-1
- #js-blob-ref-switcher{ data: { "project-id" => search_service.project.id, "ref" => repository_ref(search_service.project), "field-name": "repository_ref" } }
+ #js-blob-ref-switcher{ data: { "project-id" => @project.id, "ref" => repository_ref(@project), "field-name": "repository_ref" } }
= s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project }
- else
= _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project }
- - elsif search_service.group
- - link_to_group = link_to(search_service.group.name, search_service.group, class: 'ml-md-1')
+ - elsif @group
+ - link_to_group = link_to(@group.name, @group, class: 'ml-md-1')
= _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group }
- - if search_service.show_sort_dropdown?
+ - if @search_service_presenter.show_sort_dropdown?
.gl-md-display-flex.gl-flex-direction-column
#js-search-sort{ data: { "search-sort-options" => search_sort_options.to_json } }
diff --git a/app/views/search/_results_status_vert_nav.html.haml b/app/views/search/_results_status_vert_nav.html.haml
index 03916911f43..29cc0a20123 100644
--- a/app/views/search/_results_status_vert_nav.html.haml
+++ b/app/views/search/_results_status_vert_nav.html.haml
@@ -2,22 +2,22 @@
.gl-display-flex.gl-flex-direction-column
.gl-p-5.gl-display-flex
.gl-md-display-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1
- - unless search_service.without_count?
- = search_entries_info(search_service.search_objects, search_service.scope, params[:search])
- - unless search_service.show_snippets?
- - if search_service.project
- - link_to_project = link_to(search_service.project.full_name, search_service.project, class: 'ml-md-1')
- - if search_service.scope == 'blobs'
+ - unless @search_service_presenter.without_count?
+ = search_entries_info(@search_objects, @scope, @search_term)
+ - unless @search_service_presenter.show_snippets?
+ - if @project
+ - link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1')
+ - if @scope == 'blobs'
= _("in")
.mx-md-1
- #js-blob-ref-switcher{ data: { "project-id" => search_service.project.id, "ref" => repository_ref(search_service.project), "field-name": "repository_ref" } }
+ #js-blob-ref-switcher{ data: { "project-id" => @project.id, "ref" => repository_ref(@project), "field-name": "repository_ref" } }
= s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project }
- else
= _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project }
- - elsif search_service.group
- - link_to_group = link_to(search_service.group.name, search_service.group, class: 'ml-md-1')
+ - elsif @group
+ - link_to_group = link_to(@group.name, @group, class: 'ml-md-1')
= _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group }
- - if search_service.show_sort_dropdown?
+ - if @search_service_presenter.show_sort_dropdown?
.gl-md-display-flex.gl-flex-direction-column
#js-search-sort{ data: { "search-sort-options" => search_sort_options.to_json } }
%hr.gl-mb-5.gl-mt-0.gl-border-gray-100.gl-w-full
diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml
index e1efa271d57..2eb6af94008 100644
--- a/app/views/search/show.html.haml
+++ b/app/views/search/show.html.haml
@@ -9,7 +9,7 @@
- project_attributes = @project&.attributes&.slice('id', 'namespace_id', 'name')&.merge(name_with_namespace: @project&.name_with_namespace)
- if @search_results
- - if @search_service.without_count?
+ - if @search_service_presenter.without_count?
- page_description(_("%{scope} results for term '%{term}'") % { scope: @scope, term: @search_term })
- else
- page_description(_("%{count} %{scope} for term '%{term}'") % { count: @search_results.formatted_count(@scope), scope: @scope, term: @search_term })
@@ -20,7 +20,7 @@
= render_if_exists 'search/form_elasticsearch', attrs: { class: 'mb-2 mb-sm-0 align-self-center' }
.gl-mt-3
- #js-search-topbar{ data: { "group-initial-json": group_attributes.to_json, "project-initial-json": project_attributes.to_json, "elasticsearch-enabled": @elasticsearch_in_use.to_s, "default-branch-name": @project&.default_branch } }
+ #js-search-topbar{ data: { "group-initial-json": group_attributes.to_json, "project-initial-json": project_attributes.to_json, "elasticsearch-enabled": @search_service_presenter.advanced_search_enabled?.to_s, "default-branch-name": @project&.default_branch } }
- if @search_term
- if Feature.disabled?(:search_page_vertical_nav, current_user)
= render 'search/category'
diff --git a/app/views/shared/_broadcast_message.html.haml b/app/views/shared/_broadcast_message.html.haml
index a202add339f..4d286713cef 100644
--- a/app/views/shared/_broadcast_message.html.haml
+++ b/app/views/shared/_broadcast_message.html.haml
@@ -3,7 +3,7 @@
- preview = local_assigns.fetch(:preview, false)
- unless message.notification?
- .gl-broadcast-message.broadcast-banner-message{ role: "alert", class: "js-broadcast-notification-#{message.id} #{message.theme}" }
+ .gl-broadcast-message.broadcast-banner-message.banner{ role: "alert", class: "js-broadcast-notification-#{message.id} #{message.theme}" }
.gl-broadcast-message-content
.gl-broadcast-message-icon
= sprite_icon(icon_name)
diff --git a/app/views/shared/_milestone_expired.html.haml b/app/views/shared/_milestone_expired.html.haml
index 8c84f96932c..c7e780d8b4b 100644
--- a/app/views/shared/_milestone_expired.html.haml
+++ b/app/views/shared/_milestone_expired.html.haml
@@ -1,6 +1 @@
-- if milestone.expired? && !milestone.closed?
- = gl_badge_tag _('Expired'), { variant: :warning }, { class: "gl-mb-2" }
-- if milestone.upcoming?
- = gl_badge_tag _('Upcoming'), { variant: :info }, { class: "gl-mb-2" }
-- if milestone.closed?
- = gl_badge_tag _('Closed'), { variant: :danger }, { class: "gl-mb-2" }
+= gl_badge_tag milestone_status_string(milestone), { variant: milestone_badge_variant(milestone) }, { class: 'gl-mb-2' }
diff --git a/app/views/shared/_milestones_sort_dropdown.html.haml b/app/views/shared/_milestones_sort_dropdown.html.haml
index b68022bfeda..5795fb29b07 100644
--- a/app/views/shared/_milestones_sort_dropdown.html.haml
+++ b/app/views/shared/_milestones_sort_dropdown.html.haml
@@ -1,4 +1,4 @@
- milestones_sort_options = milestones_sort_options_hash.map { |value, text| { value: value, text: text, href: page_filter_path(sort: value) } }
-%div{ data: {testid: 'milestone_sort_by_dropdown'} }
+%div
= gl_redirect_listbox_tag milestones_sort_options, @sort
diff --git a/app/views/shared/_ref_dropdown.html.haml b/app/views/shared/_ref_dropdown.html.haml
deleted file mode 100644
index ee2b2a17e21..00000000000
--- a/app/views/shared/_ref_dropdown.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-- dropdown_class = local_assigns.fetch(:dropdown_class, '')
-
-.dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: dropdown_class }
- = dropdown_title _('Select Git revision')
- = dropdown_filter _('Filter by Git revision')
- = dropdown_content
- = dropdown_loading
diff --git a/app/views/shared/admin/_admin_note.html.haml b/app/views/shared/admin/_admin_note.html.haml
index 9dcf181a118..2bf6baaf608 100644
--- a/app/views/shared/admin/_admin_note.html.haml
+++ b/app/views/shared/admin/_admin_note.html.haml
@@ -1,4 +1,4 @@
-- if @group.admin_note.present?
+- if @group.admin_note&.note?
- text = @group.admin_note.note
= render Pajamas::CardComponent.new(card_options: { class: 'gl-border-blue-500 gl-mb-5' }, header_options: { class: 'gl-bg-blue-500 gl-text-white' }) do |c|
- c.header do
diff --git a/app/views/shared/doorkeeper/applications/_delete_form.html.haml b/app/views/shared/doorkeeper/applications/_delete_form.html.haml
index 7cce0652f6f..b30ec2e7b3a 100644
--- a/app/views/shared/doorkeeper/applications/_delete_form.html.haml
+++ b/app/views/shared/doorkeeper/applications/_delete_form.html.haml
@@ -2,9 +2,9 @@
= form_tag path do
%input{ :name => "_method", :type => "hidden", :value => "delete" }
- if defined? small
- = button_tag type: "submit", class: "gl-button btn btn-danger btn-icon", data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" } do
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, icon: 'remove', button_options: { data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" } }) do
%span.sr-only
= _('Destroy')
- = sprite_icon('remove')
- else
- = submit_tag _('Destroy'), data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Destroy') }, class: submit_btn_css
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { aria: { label: _('Destroy') }, class: submit_btn_css, data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" } }) do
+ = _('Destroy')
diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml
index 42f6f7b71a3..e1a9b30ef67 100644
--- a/app/views/shared/issue_type/_details_content.html.haml
+++ b/app/views/shared/issue_type/_details_content.html.haml
@@ -3,7 +3,11 @@
.issue-details.issuable-details.js-issue-details
.detail-page-description.content-block.js-detail-page-description.gl-pb-0.gl-border-none
- #js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json, issuable_id: issuable.id, full_path: @project.full_path } }
+ #js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json,
+ issuable_id: issuable.id,
+ full_path: @project.full_path,
+ register_path: new_user_registration_path(redirect_to_referer: 'yes'),
+ sign_in_path: new_session_path(:user, redirect_to_referer: 'yes') } }
.title-container
%h1.title.page-title.gl-font-size-h-display= markdown_field(issuable, :title)
- if issuable.description.present?
diff --git a/app/views/shared/milestones/_description.html.haml b/app/views/shared/milestones/_description.html.haml
index 747e22f47ac..fc25c7e8f89 100644
--- a/app/views/shared/milestones/_description.html.haml
+++ b/app/views/shared/milestones/_description.html.haml
@@ -1,6 +1,11 @@
.detail-page-description.milestone-detail
%h2.gl-m-0{ data: { qa_selector: "milestone_title_content" } }
= markdown_field(milestone, :title)
+ .gl-font-sm.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { qa_selector: 'milestone_id_content' }, itemprop: 'identifier' }
+ - if can?(current_user, :read_milestone, @milestone)
+ %span.gl-display-inline-block.gl-vertical-align-middle
+ = s_('MilestonePage|Milestone ID: %{milestone_id}') % { milestone_id: @milestone.id }
+ = clipboard_button(title: s_('MilestonePage|Copy milestone ID'), text: @milestone.id)
- if milestone.try(:description).present?
%div{ data: { qa_selector: "milestone_description_content" } }
diff --git a/app/views/shared/milestones/_header.html.haml b/app/views/shared/milestones/_header.html.haml
index 334785685d5..900c71675d9 100644
--- a/app/views/shared/milestones/_header.html.haml
+++ b/app/views/shared/milestones/_header.html.haml
@@ -1,6 +1,5 @@
.detail-page-header.milestone-page-header
- .status-box{ class: status_box_class(milestone) }
- = milestone_status_string(milestone)
+ = gl_badge_tag milestone_status_string(milestone), { variant: milestone_badge_variant(milestone) }, { class: 'gl-mr-3' }
.header-text-content
%span.identifier
diff --git a/app/views/shared/nav/_your_work_scope_header.html.haml b/app/views/shared/nav/_your_work_scope_header.html.haml
new file mode 100644
index 00000000000..86172fb14ed
--- /dev/null
+++ b/app/views/shared/nav/_your_work_scope_header.html.haml
@@ -0,0 +1,6 @@
+%li.context-header
+ = link_to root_url, title: _('Your work'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do
+ %span.avatar-container.icon-avatar.rect-avatar.s32
+ = sprite_icon('work', size: 18)
+ %span.sidebar-context-title
+ = _('Your work')
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index 43cd2ee4c5b..813d8c5ed2b 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -19,7 +19,7 @@
- own_projects_illustration_path = 'illustrations/profile-page/personal-project.svg'
- own_projects_current_user_empty_message_header = s_('UserProfile|You haven\'t created any personal projects.')
- own_projects_current_user_empty_message_description = s_('UserProfile|Your projects can be available publicly, internally, or privately, at your choice.')
-- own_projects_visitor_empty_message = s_('UserProfile|This user doesn\'t have any personal projects')
+- own_projects_visitor_empty_message = s_('UserProfile|There are no projects available to be displayed here.')
- explore_page_empty_message = s_('UserProfile|Explore public groups to find projects to contribute to.')
- new_project_button_label = _('New project')
- new_project_button_link = new_project_path
diff --git a/app/views/shared/runners/_runner_type_alert.html.haml b/app/views/shared/runners/_runner_type_alert.html.haml
index 9736780c436..a1599b3ec49 100644
--- a/app/views/shared/runners/_runner_type_alert.html.haml
+++ b/app/views/shared/runners/_runner_type_alert.html.haml
@@ -12,5 +12,5 @@
title: s_('Runners|This runner is associated with specific projects.'),
dismissible: false) do |c|
= c.body do
- = s_('Runners|You can set up a specific runner to be used by multiple projects but you cannot make this a shared runner.')
+ = s_('Runners|You can set up a specific runner to be used by multiple projects but you cannot make this a shared or group runner.')
= link_to _('Learn more.'), help_page_path('ci/runners/runners_scope', anchor: 'specific-runners'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/snippets/edit.html.haml b/app/views/snippets/edit.html.haml
index 5fa4a6775f9..2b2035e362b 100644
--- a/app/views/snippets/edit.html.haml
+++ b/app/views/snippets/edit.html.haml
@@ -1,5 +1,5 @@
+- breadcrumb_title _("Edit Snippet")
- page_title _("Edit"), "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
-- @content_class = "limit-container-width" unless fluid_layout
- content_for :prefetch_asset_tags do
- webpack_preload_asset_tag('monaco')
diff --git a/app/views/snippets/new.html.haml b/app/views/snippets/new.html.haml
index 418f96a1024..da2245432ce 100644
--- a/app/views/snippets/new.html.haml
+++ b/app/views/snippets/new.html.haml
@@ -1,7 +1,4 @@
-- @hide_top_links = true
-- @hide_breadcrumbs = true
- page_title _("New Snippet")
-- @content_class = "limit-container-width" unless fluid_layout
.page-title-holder.d-flex.align-items-center
%h1.page-title.gl-font-size-h-display= _('New Snippet')
diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml
index bb43c3c6274..eb9465a409f 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/snippets/show.html.haml
@@ -5,7 +5,6 @@
- else
- add_page_startup_graphql_call('snippet/user_permissions')
- @hide_top_links = true
-- @content_class = "limit-container-width limited-inner-width-container" unless fluid_layout
- add_to_breadcrumbs _("Snippets"), dashboard_snippets_path
- breadcrumb_title @snippet.to_reference
- page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml
index 8da1aa09215..3571031fbfa 100644
--- a/app/views/users/calendar_activities.html.haml
+++ b/app/views/users/calendar_activities.html.haml
@@ -20,7 +20,7 @@
- if event.note?
= link_to event.note_target.to_reference, event_note_target_url(event), class: 'has-tooltip', title: event.target_title
- elsif event.target
- = link_to event.target.to_reference, [event.project, event.target], class: 'has-tooltip', title: event.target_title
+ = link_to event.target.to_reference, Gitlab::UrlBuilder.build(event.target, only_path: true), class: 'has-tooltip', title: event.target_title
= s_('UserProfile|at')
%strong
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 03ecf8cac22..af29de6b0c4 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -18,14 +18,7 @@
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
- = 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
- = 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: _('Report abuse to administrator'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }})
+ #js-report-abuse{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: @user.id, reported_from_url: user_url(@user) } }
- verified_gpg_keys = @user.gpg_keys.select(&:verified?)
- if verified_gpg_keys.any?
= render Pajamas::ButtonComponent.new(href: user_gpg_keys_path,
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 652a0021b0f..693afdea43a 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -698,7 +698,7 @@
:tags: []
- :name: cronjob:remove_unreferenced_lfs_objects
:worker_name: RemoveUnreferencedLfsObjectsWorker
- :feature_category: :git_lfs
+ :feature_category: :source_code_management
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -725,7 +725,7 @@
:tags: []
- :name: cronjob:schedule_merge_request_cleanup_refs
:worker_name: ScheduleMergeRequestCleanupRefsWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -734,7 +734,7 @@
:tags: []
- :name: cronjob:schedule_migrate_external_diffs
:worker_name: ScheduleMigrateExternalDiffsWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -779,7 +779,7 @@
:tags: []
- :name: cronjob:stuck_merge_jobs
:worker_name: StuckMergeJobsWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -951,11 +951,11 @@
- :name: gcp_cluster:cluster_provision
:worker_name: ClusterProvisionWorker
:feature_category: :kubernetes_management
- :has_external_dependencies: true
+ :has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent: false
+ :idempotent: true
:tags: []
- :name: gcp_cluster:cluster_update_app
:worker_name: ClusterUpdateAppWorker
@@ -1059,11 +1059,11 @@
- :name: gcp_cluster:wait_for_cluster_creation
:worker_name: WaitForClusterCreationWorker
:feature_category: :kubernetes_management
- :has_external_dependencies: true
+ :has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent: false
+ :idempotent: true
:tags: []
- :name: github_gists_importer:github_gists_import_finish_import
:worker_name: Gitlab::GithubGistsImport::FinishImportWorker
@@ -2282,7 +2282,7 @@
:tags: []
- :name: chat_notification
:worker_name: ChatNotificationWorker
- :feature_category: :chatops
+ :feature_category: :integrations
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
@@ -2334,6 +2334,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: counters_cleanup_refresh
+ :worker_name: Counters::CleanupRefreshWorker
+ :feature_category: :not_owned
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: create_commit_signature
:worker_name: CreateCommitSignatureWorker
:feature_category: :source_code_management
@@ -2345,7 +2354,7 @@
:tags: []
- :name: create_note_diff_file
:worker_name: CreateNoteDiffFileWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -2363,7 +2372,7 @@
:tags: []
- :name: delete_diff_files
:worker_name: DeleteDiffFilesWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -2750,7 +2759,7 @@
:tags: []
- :name: merge_request_cleanup_refs
:worker_name: MergeRequestCleanupRefsWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -2759,7 +2768,7 @@
:tags: []
- :name: merge_request_mergeability_check
:worker_name: MergeRequestMergeabilityCheckWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -2768,7 +2777,7 @@
:tags: []
- :name: merge_requests_close_issue
:worker_name: MergeRequests::CloseIssueWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
@@ -2777,7 +2786,7 @@
:tags: []
- :name: merge_requests_create_approval_event
:worker_name: MergeRequests::CreateApprovalEventWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -2786,7 +2795,7 @@
:tags: []
- :name: merge_requests_create_approval_note
:worker_name: MergeRequests::CreateApprovalNoteWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -2804,7 +2813,7 @@
:tags: []
- :name: merge_requests_execute_approval_hooks
:worker_name: MergeRequests::ExecuteApprovalHooksWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
@@ -2813,7 +2822,7 @@
:tags: []
- :name: merge_requests_handle_assignees_change
:worker_name: MergeRequests::HandleAssigneesChangeWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: false
:urgency: :high
:resource_boundary: :unknown
@@ -2822,7 +2831,7 @@
:tags: []
- :name: merge_requests_resolve_todos
:worker_name: MergeRequests::ResolveTodosWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: false
:urgency: :high
:resource_boundary: :unknown
@@ -2831,7 +2840,7 @@
:tags: []
- :name: merge_requests_resolve_todos_after_approval
:worker_name: MergeRequests::ResolveTodosAfterApprovalWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -2840,7 +2849,7 @@
:tags: []
- :name: merge_requests_update_head_pipeline
:worker_name: MergeRequests::UpdateHeadPipelineWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: false
:urgency: :high
:resource_boundary: :cpu
@@ -2867,7 +2876,7 @@
:tags: []
- :name: migrate_external_diffs
:worker_name: MigrateExternalDiffsWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -2903,7 +2912,7 @@
:tags: []
- :name: new_merge_request
:worker_name: NewMergeRequestWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: false
:urgency: :high
:resource_boundary: :cpu
@@ -3072,6 +3081,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: projects_finalize_project_statistics_refresh
+ :worker_name: Projects::FinalizeProjectStatisticsRefreshWorker
+ :feature_category: :not_owned
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: projects_git_garbage_collect
:worker_name: Projects::GitGarbageCollectWorker
:feature_category: :gitaly
@@ -3362,7 +3380,7 @@
:tags: []
- :name: update_merge_requests
:worker_name: UpdateMergeRequestsWorker
- :feature_category: :code_review
+ :feature_category: :code_review_workflow
:has_external_dependencies: false
:urgency: :high
:resource_boundary: :cpu
diff --git a/app/workers/chat_notification_worker.rb b/app/workers/chat_notification_worker.rb
index 23d8a1ec29d..4ee32a43808 100644
--- a/app/workers/chat_notification_worker.rb
+++ b/app/workers/chat_notification_worker.rb
@@ -8,7 +8,7 @@ class ChatNotificationWorker # rubocop:disable Scalability/IdempotentWorker
TimeoutExceeded = Class.new(StandardError)
sidekiq_options retry: false
- feature_category :chatops
+ feature_category :integrations
urgency :low # Can't be high as it has external dependencies
weight 2
worker_has_external_dependencies!
diff --git a/app/workers/ci/build_finished_worker.rb b/app/workers/ci/build_finished_worker.rb
index 7503ea3d800..2113f7ae07b 100644
--- a/app/workers/ci/build_finished_worker.rb
+++ b/app/workers/ci/build_finished_worker.rb
@@ -40,6 +40,7 @@ module Ci
ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat?
build.track_deployment_usage
build.track_verify_environment_usage
+ build.remove_token!
if build.failed? && !build.auto_retry_expected?
::Ci::MergeRequests::AddTodoWhenBuildFailsWorker.perform_async(build.id)
diff --git a/app/workers/ci/initial_pipeline_process_worker.rb b/app/workers/ci/initial_pipeline_process_worker.rb
index 8d7a62e5b09..734755f176a 100644
--- a/app/workers/ci/initial_pipeline_process_worker.rb
+++ b/app/workers/ci/initial_pipeline_process_worker.rb
@@ -17,10 +17,22 @@ module Ci
def perform(pipeline_id)
Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
+ create_deployments!(pipeline) if Feature.enabled?(:move_create_deployments_to_worker, pipeline.project)
+
Ci::PipelineCreation::StartPipelineService
.new(pipeline)
.execute
end
end
+
+ private
+
+ def create_deployments!(pipeline)
+ pipeline.stages.flat_map(&:statuses).each { |build| create_deployment(build) }
+ end
+
+ def create_deployment(build)
+ ::Deployments::CreateForBuildService.new.execute(build)
+ end
end
end
diff --git a/app/workers/cluster_provision_worker.rb b/app/workers/cluster_provision_worker.rb
index 04c9174347f..6f3615d249c 100644
--- a/app/workers/cluster_provision_worker.rb
+++ b/app/workers/cluster_provision_worker.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ClusterProvisionWorker # rubocop:disable Scalability/IdempotentWorker
+class ClusterProvisionWorker
include ApplicationWorker
data_consistency :always
@@ -8,17 +8,7 @@ class ClusterProvisionWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: 3
include ClusterQueue
- worker_has_external_dependencies!
+ idempotent!
- def perform(cluster_id)
- Clusters::Cluster.find_by_id(cluster_id).try do |cluster|
- cluster.provider.try do |provider|
- if cluster.gcp?
- Clusters::Gcp::ProvisionService.new.execute(provider)
- elsif cluster.aws?
- Clusters::Aws::ProvisionService.new.execute(provider)
- end
- end
- end
- end
+ def perform(_); end
end
diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb
index f51c2852da6..222d045b0ba 100644
--- a/app/workers/concerns/application_worker.rb
+++ b/app/workers/concerns/application_worker.rb
@@ -16,6 +16,7 @@ module ApplicationWorker
SAFE_PUSH_BULK_LIMIT = 1000
included do
+ prefer_calling_context_feature_category false
set_queue
after_set_class_attribute { set_queue }
diff --git a/app/workers/concerns/git_garbage_collect_methods.rb b/app/workers/concerns/git_garbage_collect_methods.rb
index 5c0493c9be5..c5f8c9c8464 100644
--- a/app/workers/concerns/git_garbage_collect_methods.rb
+++ b/app/workers/concerns/git_garbage_collect_methods.rb
@@ -82,28 +82,12 @@ module GitGarbageCollectMethods
def gitaly_call(task, resource)
repository = resource.repository.raw_repository
+ client = repository.gitaly_repository_client
- if Feature.enabled?(:optimized_housekeeping, container(resource))
- client = repository.gitaly_repository_client
-
- if task == :prune
- client.prune_unreachable_objects
- else
- client.optimize_repository
- end
+ if task == :prune
+ client.prune_unreachable_objects
else
- client = get_gitaly_client(task, repository)
-
- case task
- when :prune, :gc
- client.garbage_collect(bitmaps_enabled?, prune: task == :prune)
- when :full_repack
- client.repack_full(bitmaps_enabled?)
- when :incremental_repack
- client.repack_incremental
- when :pack_refs
- client.pack_refs
- end
+ client.optimize_repository
end
rescue GRPC::NotFound => e
Gitlab::GitLogger.error("#{__method__} failed:\nRepository not found")
@@ -113,22 +97,6 @@ module GitGarbageCollectMethods
raise Gitlab::Git::CommandError, e
end
- def get_gitaly_client(task, repository)
- if task == :pack_refs
- Gitlab::GitalyClient::RefService
- else
- Gitlab::GitalyClient::RepositoryService
- end.new(repository)
- end
-
- # The option to enable/disable bitmaps has been removed in https://gitlab.com/gitlab-org/gitlab/-/issues/353777
- # Now the options is always enabled
- # This method and all the deprecated RPCs are going to be removed in
- # https://gitlab.com/gitlab-org/gitlab/-/issues/353779
- def bitmaps_enabled?
- true
- end
-
def flush_ref_caches(resource)
resource.repository.expire_branches_cache
resource.repository.branch_names
@@ -136,8 +104,6 @@ module GitGarbageCollectMethods
end
def update_repository_statistics(resource, task)
- return if task == :pack_refs
-
resource.repository.expire_statistics_caches
return if Gitlab::Database.read_only? # GitGarbageCollectWorker may be run on a Geo secondary
diff --git a/app/workers/concerns/update_repository_storage_worker.rb b/app/workers/concerns/update_repository_storage_worker.rb
index f46b64895a2..01744d1e57d 100644
--- a/app/workers/concerns/update_repository_storage_worker.rb
+++ b/app/workers/concerns/update_repository_storage_worker.rb
@@ -5,6 +5,7 @@ module UpdateRepositoryStorageWorker
include ApplicationWorker
included do
+ deduplicate :until_executed
idempotent!
feature_category :gitaly
urgency :throttled
diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb
index 8a135bc1853..1674ed1483a 100644
--- a/app/workers/concerns/worker_attributes.rb
+++ b/app/workers/concerns/worker_attributes.rb
@@ -38,12 +38,17 @@ module WorkerAttributes
set_class_attribute(:feature_category, value)
end
+ def prefer_calling_context_feature_category(preference = false)
+ set_class_attribute(:prefer_calling_context_feature_category, preference)
+ end
+
# Special case: if a worker is not owned, get the feature category
# (if present) from the calling context.
def get_feature_category
feature_category = get_class_attribute(:feature_category)
+ calling_context_feature_category_preferred = !!get_class_attribute(:prefer_calling_context_feature_category)
- return feature_category unless feature_category == :not_owned
+ return feature_category unless feature_category == :not_owned || calling_context_feature_category_preferred
Gitlab::ApplicationContext.current_context_attribute('meta.feature_category') || feature_category
end
diff --git a/app/workers/counters/cleanup_refresh_worker.rb b/app/workers/counters/cleanup_refresh_worker.rb
new file mode 100644
index 00000000000..97e6a56d6e7
--- /dev/null
+++ b/app/workers/counters/cleanup_refresh_worker.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Counters
+ class CleanupRefreshWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ loggable_arguments 0, 2
+
+ # The counter is owned by several teams depending on the attribute
+ feature_category :not_owned # rubocop:disable Gitlab/AvoidFeatureCategoryNotOwned
+
+ urgency :low
+ deduplicate :until_executing, including_scheduled: true
+
+ idempotent!
+
+ def perform(model_name, model_id, attribute)
+ Gitlab::ApplicationContext.push(feature_category: :build_artifacts) if attribute.to_s == 'build_artifacts_size'
+
+ return unless self.class.const_defined?(model_name)
+
+ model_class = model_name.constantize
+ model = model_class.find_by_id(model_id)
+ return unless model
+
+ Gitlab::Counters::BufferedCounter.new(model, attribute).cleanup_refresh
+ end
+ end
+end
diff --git a/app/workers/create_note_diff_file_worker.rb b/app/workers/create_note_diff_file_worker.rb
index 8481fd0a2ab..c5b5e1cef41 100644
--- a/app/workers/create_note_diff_file_worker.rb
+++ b/app/workers/create_note_diff_file_worker.rb
@@ -7,7 +7,7 @@ class CreateNoteDiffFileWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: 3
- feature_category :code_review
+ feature_category :code_review_workflow
def perform(diff_note_id)
return unless diff_note_id.present?
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 e772216e557..b7b46937db2 100644
--- a/app/workers/database/batched_background_migration/single_database_worker.rb
+++ b/app/workers/database/batched_background_migration/single_database_worker.rb
@@ -16,6 +16,7 @@ module Database
included do
data_consistency :always
feature_category :database
+ prefer_calling_context_feature_category true
idempotent!
end
diff --git a/app/workers/delete_diff_files_worker.rb b/app/workers/delete_diff_files_worker.rb
index 54d8fcb6dfd..f9f5e6ed35b 100644
--- a/app/workers/delete_diff_files_worker.rb
+++ b/app/workers/delete_diff_files_worker.rb
@@ -7,7 +7,7 @@ class DeleteDiffFilesWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: 3
- feature_category :code_review
+ feature_category :code_review_workflow
# rubocop: disable CodeReuse/ActiveRecord
def perform(merge_request_diff_id)
diff --git a/app/workers/gitlab/github_gists_import/finish_import_worker.rb b/app/workers/gitlab/github_gists_import/finish_import_worker.rb
index 1989b6314ea..284e5833f0c 100644
--- a/app/workers/gitlab/github_gists_import/finish_import_worker.rb
+++ b/app/workers/gitlab/github_gists_import/finish_import_worker.rb
@@ -18,14 +18,15 @@ module Gitlab
INTERVAL = 30.seconds.to_i
BLOCKING_WAIT_TIME = 5
+ GISTS_ERRORS_BY_ID = 'gitlab:github-gists-import:%{user_id}:errors'
def perform(user_id, waiter_key, remaining)
waiter = wait_for_jobs(waiter_key, remaining)
if waiter.nil?
Gitlab::GithubGistsImport::Status.new(user_id).finish!
-
Gitlab::GithubImport::Logger.info(user_id: user_id, message: 'GitHub Gists import finished')
+ send_email_if_errors(user_id)
else
self.class.perform_in(INTERVAL, user_id, waiter.key, waiter.jobs_remaining)
end
@@ -41,6 +42,17 @@ module Gitlab
waiter
end
+
+ def send_email_if_errors(user_id)
+ key = format(GISTS_ERRORS_BY_ID, user_id: user_id)
+ errors = ::Gitlab::Cache::Import::Caching.values_from_hash(key)
+
+ return if errors.blank?
+
+ Notify.github_gists_import_errors_email(user_id, errors).deliver_now
+ ensure
+ ::Gitlab::Cache::Import::Caching.expire(key, ::Gitlab::Cache::Import::Caching::SHORTER_TIMEOUT)
+ end
end
end
end
diff --git a/app/workers/gitlab/github_gists_import/import_gist_worker.rb b/app/workers/gitlab/github_gists_import/import_gist_worker.rb
index 7e2b3709597..fb7fb661f4c 100644
--- a/app/workers/gitlab/github_gists_import/import_gist_worker.rb
+++ b/app/workers/gitlab/github_gists_import/import_gist_worker.rb
@@ -6,6 +6,8 @@ module Gitlab
include ApplicationWorker
include Gitlab::NotifyUponDeath
+ GISTS_ERRORS_BY_ID = 'gitlab:github-gists-import:%{user_id}:errors'
+
data_consistency :always
queue_namespace :github_gists_importer
feature_category :importers
@@ -33,16 +35,16 @@ module Gitlab
::Gitlab::GithubGistsImport::Importer::GistImporter
end
- def with_logging(user_id, gist_id)
- info(user_id, 'start importer', gist_id)
+ def with_logging(user_id, github_identifiers)
+ info(user_id, 'start importer', github_identifiers)
yield
- info(user_id, 'importer finished', gist_id)
+ info(user_id, 'importer finished', github_identifiers)
end
- def log_and_track_error(user_id, exception, gist_id)
- error(user_id, exception.message, gist_id)
+ def log_and_track_error(user_id, exception, github_identifiers)
+ error(user_id, exception.message, github_identifiers)
Gitlab::ErrorTracking.track_exception(exception,
import_type: :github_gists,
@@ -50,15 +52,17 @@ module Gitlab
)
end
- def error(user_id, error_message, gist_id)
+ def error(user_id, error_message, github_identifiers)
attributes = {
user_id: user_id,
- github_identifiers: gist_id,
+ github_identifiers: github_identifiers,
message: 'importer failed',
'error.message': error_message
}
Gitlab::GithubImport::Logger.error(structured_payload(attributes))
+
+ cache_error_for_email(user_id, github_identifiers[:id], error_message)
end
def info(user_id, message, gist_id)
@@ -70,6 +74,12 @@ module Gitlab
Gitlab::GithubImport::Logger.info(structured_payload(attributes))
end
+
+ def cache_error_for_email(user_id, gist_id, error_message)
+ key = format(GISTS_ERRORS_BY_ID, user_id: user_id)
+
+ ::Gitlab::Cache::Import::Caching.hash_add(key, gist_id, error_message)
+ end
end
end
end
diff --git a/app/workers/issues/rebalancing_worker.rb b/app/workers/issues/rebalancing_worker.rb
index 8de0588a2a1..14cb97ab0e8 100644
--- a/app/workers/issues/rebalancing_worker.rb
+++ b/app/workers/issues/rebalancing_worker.rb
@@ -19,7 +19,7 @@ module Issues
return if project_id.nil? && root_namespace_id.nil?
return if ::Gitlab::Issues::Rebalancing::State.rebalance_recently_finished?(project_id, root_namespace_id)
- # pull the projects collection to be rebalanced either the project if namespace is not a group(i.e. user namesapce)
+ # pull the projects collection to be rebalanced either the project if namespace is not a group(i.e. user namespace)
# or the root namespace, this also makes the worker backward compatible with previous version where a project_id was
# passed as the param
projects_to_rebalance = projects_collection(project_id, root_namespace_id)
diff --git a/app/workers/merge_request_cleanup_refs_worker.rb b/app/workers/merge_request_cleanup_refs_worker.rb
index db6f4649f47..92dfe8a8cb0 100644
--- a/app/workers/merge_request_cleanup_refs_worker.rb
+++ b/app/workers/merge_request_cleanup_refs_worker.rb
@@ -9,7 +9,7 @@ class MergeRequestCleanupRefsWorker
sidekiq_options retry: 3
- feature_category :code_review
+ feature_category :code_review_workflow
idempotent!
# Hard-coded to 4 for now. Will be configurable later on via application settings.
diff --git a/app/workers/merge_request_mergeability_check_worker.rb b/app/workers/merge_request_mergeability_check_worker.rb
index 0e1ab505644..2ef4220131a 100644
--- a/app/workers/merge_request_mergeability_check_worker.rb
+++ b/app/workers/merge_request_mergeability_check_worker.rb
@@ -7,7 +7,7 @@ class MergeRequestMergeabilityCheckWorker
sidekiq_options retry: 3
- feature_category :code_review
+ feature_category :code_review_workflow
idempotent!
def logger
diff --git a/app/workers/merge_requests/close_issue_worker.rb b/app/workers/merge_requests/close_issue_worker.rb
index 86d63e571ac..8c3ba1bc5ab 100644
--- a/app/workers/merge_requests/close_issue_worker.rb
+++ b/app/workers/merge_requests/close_issue_worker.rb
@@ -5,7 +5,7 @@ module MergeRequests
include ApplicationWorker
data_consistency :always
- feature_category :code_review
+ feature_category :code_review_workflow
urgency :low
idempotent!
diff --git a/app/workers/merge_requests/create_approval_event_worker.rb b/app/workers/merge_requests/create_approval_event_worker.rb
index 9b1a3c262e4..09ae51e943c 100644
--- a/app/workers/merge_requests/create_approval_event_worker.rb
+++ b/app/workers/merge_requests/create_approval_event_worker.rb
@@ -5,7 +5,7 @@ module MergeRequests
include Gitlab::EventStore::Subscriber
data_consistency :always
- feature_category :code_review
+ feature_category :code_review_workflow
urgency :low
idempotent!
diff --git a/app/workers/merge_requests/create_approval_note_worker.rb b/app/workers/merge_requests/create_approval_note_worker.rb
index 841431f6a9d..18b0533169a 100644
--- a/app/workers/merge_requests/create_approval_note_worker.rb
+++ b/app/workers/merge_requests/create_approval_note_worker.rb
@@ -5,7 +5,7 @@ module MergeRequests
include Gitlab::EventStore::Subscriber
data_consistency :always
- feature_category :code_review
+ feature_category :code_review_workflow
urgency :low
idempotent!
diff --git a/app/workers/merge_requests/create_pipeline_worker.rb b/app/workers/merge_requests/create_pipeline_worker.rb
index b40408cf647..096f2500019 100644
--- a/app/workers/merge_requests/create_pipeline_worker.rb
+++ b/app/workers/merge_requests/create_pipeline_worker.rb
@@ -25,11 +25,15 @@ module MergeRequests
merge_request = MergeRequest.find_by_id(merge_request_id)
return unless merge_request
+ allow_duplicate = params.with_indifferent_access[:allow_duplicate]
push_options = params.with_indifferent_access[:push_options]
MergeRequests::CreatePipelineService
- .new(project: project, current_user: user, params: { push_options: push_options })
- .execute(merge_request)
+ .new(
+ project: project,
+ current_user: user,
+ params: { allow_duplicate: allow_duplicate, push_options: push_options }
+ ).execute(merge_request)
merge_request.update_head_pipeline
end
diff --git a/app/workers/merge_requests/execute_approval_hooks_worker.rb b/app/workers/merge_requests/execute_approval_hooks_worker.rb
index 81eca425a38..0a127e16f26 100644
--- a/app/workers/merge_requests/execute_approval_hooks_worker.rb
+++ b/app/workers/merge_requests/execute_approval_hooks_worker.rb
@@ -5,7 +5,7 @@ module MergeRequests
include Gitlab::EventStore::Subscriber
data_consistency :always
- feature_category :code_review
+ feature_category :code_review_workflow
urgency :low
idempotent!
diff --git a/app/workers/merge_requests/handle_assignees_change_worker.rb b/app/workers/merge_requests/handle_assignees_change_worker.rb
index 7cf1be51d23..05e02905008 100644
--- a/app/workers/merge_requests/handle_assignees_change_worker.rb
+++ b/app/workers/merge_requests/handle_assignees_change_worker.rb
@@ -7,7 +7,7 @@ class MergeRequests::HandleAssigneesChangeWorker
sidekiq_options retry: 3
- feature_category :code_review
+ feature_category :code_review_workflow
urgency :high
deduplicate :until_executed
idempotent!
diff --git a/app/workers/merge_requests/resolve_todos_after_approval_worker.rb b/app/workers/merge_requests/resolve_todos_after_approval_worker.rb
index 7d9c76ea872..ef02e501de1 100644
--- a/app/workers/merge_requests/resolve_todos_after_approval_worker.rb
+++ b/app/workers/merge_requests/resolve_todos_after_approval_worker.rb
@@ -5,7 +5,7 @@ module MergeRequests
include Gitlab::EventStore::Subscriber
data_consistency :always
- feature_category :code_review
+ feature_category :code_review_workflow
urgency :low
idempotent!
diff --git a/app/workers/merge_requests/resolve_todos_worker.rb b/app/workers/merge_requests/resolve_todos_worker.rb
index 314cdac4414..15bf92c8ae1 100644
--- a/app/workers/merge_requests/resolve_todos_worker.rb
+++ b/app/workers/merge_requests/resolve_todos_worker.rb
@@ -7,7 +7,7 @@ class MergeRequests::ResolveTodosWorker
sidekiq_options retry: 3
- feature_category :code_review
+ feature_category :code_review_workflow
urgency :high
deduplicate :until_executed
idempotent!
diff --git a/app/workers/merge_requests/update_head_pipeline_worker.rb b/app/workers/merge_requests/update_head_pipeline_worker.rb
index bc3a289c1e1..2479bc51cc4 100644
--- a/app/workers/merge_requests/update_head_pipeline_worker.rb
+++ b/app/workers/merge_requests/update_head_pipeline_worker.rb
@@ -4,7 +4,7 @@ module MergeRequests
class UpdateHeadPipelineWorker
include Gitlab::EventStore::Subscriber
- feature_category :code_review
+ feature_category :code_review_workflow
urgency :high
worker_resource_boundary :cpu
data_consistency :always
diff --git a/app/workers/migrate_external_diffs_worker.rb b/app/workers/migrate_external_diffs_worker.rb
index 566797d8b8a..4f1393a631c 100644
--- a/app/workers/migrate_external_diffs_worker.rb
+++ b/app/workers/migrate_external_diffs_worker.rb
@@ -7,7 +7,7 @@ class MigrateExternalDiffsWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: 3
- feature_category :code_review
+ feature_category :code_review_workflow
def perform(merge_request_diff_id)
diff = MergeRequestDiff.find_by_id(merge_request_diff_id)
diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb
index 9694d44e8c1..d6e8d517b5a 100644
--- a/app/workers/new_merge_request_worker.rb
+++ b/app/workers/new_merge_request_worker.rb
@@ -8,7 +8,7 @@ class NewMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: 3
include NewIssuable
- feature_category :code_review
+ feature_category :code_review_workflow
urgency :high
worker_resource_boundary :cpu
weight 2
diff --git a/app/workers/pages/invalidate_domain_cache_worker.rb b/app/workers/pages/invalidate_domain_cache_worker.rb
index 97e8966b342..1700b681b94 100644
--- a/app/workers/pages/invalidate_domain_cache_worker.rb
+++ b/app/workers/pages/invalidate_domain_cache_worker.rb
@@ -9,9 +9,9 @@ module Pages
feature_category :pages
def handle_event(event)
- if event.data[:project_id]
+ domain_ids(event).each do |domain_id|
::Gitlab::Pages::CacheControl
- .for_project(event.data[:project_id])
+ .for_domain(domain_id)
.clear_cache
end
@@ -25,5 +25,13 @@ module Pages
.clear_cache
end
end
+
+ def domain_ids(event)
+ ids = PagesDomain.ids_for_project(event.data[:project_id])
+
+ ids << event.data[:domain_id] if event.data[:domain_id]
+
+ ids
+ end
end
end
diff --git a/app/workers/personal_access_tokens/expired_notification_worker.rb b/app/workers/personal_access_tokens/expired_notification_worker.rb
index 2d0ea3d3aa4..b119957fa2c 100644
--- a/app/workers/personal_access_tokens/expired_notification_worker.rb
+++ b/app/workers/personal_access_tokens/expired_notification_worker.rb
@@ -10,16 +10,28 @@ module PersonalAccessTokens
feature_category :authentication_and_authorization
+ MAX_TOKENS = 100
+
def perform(*args)
notification_service = NotificationService.new
User.with_personal_access_tokens_expired_today.find_each do |user|
with_context(user: user) do
- Gitlab::AppLogger.info "#{self.class}: Notifying User #{user.id} about an expired token"
+ expiring_user_tokens = user.personal_access_tokens.without_impersonation.expired_today_and_not_notified
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ # We never materialise the token instances. We need the names to mention them in the
+ # email. Later we trigger an update query on the entire relation, not on individual instances.
+ token_names = expiring_user_tokens.limit(MAX_TOKENS).pluck(:name)
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ notification_service.access_token_expired(user, token_names)
- notification_service.access_token_expired(user)
+ Gitlab::AppLogger.info "#{self.class}: Notifying User #{user.id} about expired tokens"
- user.personal_access_tokens.without_impersonation.expired_today_and_not_notified.update_all(after_expiry_notification_delivered: true)
+ expiring_user_tokens.each_batch do |expiring_tokens|
+ expiring_tokens.update_all(after_expiry_notification_delivered: true)
+ end
end
end
end
diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb
index 5a53d53ccf9..fb843bd421c 100644
--- a/app/workers/pipeline_schedule_worker.rb
+++ b/app/workers/pipeline_schedule_worker.rb
@@ -6,19 +6,52 @@ class PipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker
data_consistency :always
include CronjobQueue
+ include ::Gitlab::ExclusiveLeaseHelpers
+
+ LOCK_RETRY = 3
+ LOCK_TTL = 5.minutes
feature_category :continuous_integration
worker_resource_boundary :cpu
def perform
- Ci::PipelineSchedule.runnable_schedules.preloaded.find_in_batches do |schedules|
- schedules.each do |schedule|
- next unless schedule.project
+ if Feature.enabled?(:ci_use_run_pipeline_schedule_worker)
+ in_lock(lock_key, **lock_params) do
+ Ci::PipelineSchedule
+ .select(:id, :owner_id, :project_id) # Minimize the selected columns
+ .runnable_schedules
+ .preloaded
+ .find_in_batches do |schedules|
+ RunPipelineScheduleWorker.bulk_perform_async_with_contexts(
+ schedules,
+ arguments_proc: ->(schedule) { [schedule.id, schedule.owner_id] },
+ context_proc: ->(schedule) { { project: schedule.project, user: schedule.owner } }
+ )
+ end
+ end
+ else
+ Ci::PipelineSchedule.runnable_schedules.preloaded.find_in_batches do |schedules|
+ schedules.each do |schedule|
+ next unless schedule.project
- with_context(project: schedule.project, user: schedule.owner) do
- Ci::PipelineScheduleService.new(schedule.project, schedule.owner).execute(schedule)
+ with_context(project: schedule.project, user: schedule.owner) do
+ Ci::PipelineScheduleService.new(schedule.project, schedule.owner).execute(schedule)
+ end
end
end
end
end
+
+ private
+
+ def lock_key
+ self.class.name.underscore
+ end
+
+ def lock_params
+ {
+ ttl: LOCK_TTL,
+ retries: LOCK_RETRY
+ }
+ end
end
diff --git a/app/workers/projects/delete_branch_worker.rb b/app/workers/projects/delete_branch_worker.rb
index 1949fb67e83..339fa478de9 100644
--- a/app/workers/projects/delete_branch_worker.rb
+++ b/app/workers/projects/delete_branch_worker.rb
@@ -4,6 +4,9 @@ module Projects
class DeleteBranchWorker
include ApplicationWorker
+ # Temporary error when Gitaly cannot lock the branch reference. A retry should solve it.
+ GitReferenceLockedError = Class.new(::Gitlab::SidekiqMiddleware::RetryError)
+
data_consistency :always
feature_category :source_code_management
@@ -20,11 +23,10 @@ module Projects
delete_service_result = ::Branches::DeleteService.new(project, user)
.execute(branch_name)
- return unless Feature.enabled?(:track_and_raise_delete_source_errors, project)
# Only want to raise on 400 to avoid permission and non existant branch error
return unless delete_service_result[:http_status] == 400
- delete_service_result.track_and_raise_exception
+ delete_service_result.log_and_raise_exception(as: GitReferenceLockedError)
end
end
end
diff --git a/app/workers/projects/finalize_project_statistics_refresh_worker.rb b/app/workers/projects/finalize_project_statistics_refresh_worker.rb
new file mode 100644
index 00000000000..d8b06f55c38
--- /dev/null
+++ b/app/workers/projects/finalize_project_statistics_refresh_worker.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Projects
+ class FinalizeProjectStatisticsRefreshWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ loggable_arguments 0, 1
+
+ # The increments in `ProjectStatistics` are owned by several teams depending
+ # on the counter
+ feature_category :not_owned # rubocop:disable Gitlab/AvoidFeatureCategoryNotOwned
+
+ urgency :low
+ deduplicate :until_executing, including_scheduled: true
+
+ idempotent!
+
+ def perform(record_class, record_id)
+ if record_class.demodulize == 'BuildArtifactsSizeRefresh'
+ Gitlab::ApplicationContext.push(feature_category: :build_artifacts)
+ end
+
+ return unless self.class.const_defined?(record_class)
+
+ record = record_class.constantize.find_by_id(record_id)
+ return unless record
+
+ record.finalize!
+ end
+ end
+end
diff --git a/app/workers/projects/git_garbage_collect_worker.rb b/app/workers/projects/git_garbage_collect_worker.rb
index 9ac3953e83c..8c0100dd05b 100644
--- a/app/workers/projects/git_garbage_collect_worker.rb
+++ b/app/workers/projects/git_garbage_collect_worker.rb
@@ -7,12 +7,6 @@ module Projects
private
- # Used for getting a project/group out of the resource in order to scope a feature flag
- # Can be removed within https://gitlab.com/gitlab-org/gitlab/-/issues/353607
- def container(resource)
- resource
- end
-
override :find_resource
def find_resource(id)
Project.find(id)
diff --git a/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb b/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb
index 705bf0534f7..ec23bde5898 100644
--- a/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb
+++ b/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb
@@ -11,6 +11,10 @@ module Projects
idempotent!
+ MAX_RUNNING_LOW = 1
+ MAX_RUNNING_MEDIUM = 3
+ MAX_RUNNING_HIGH = 5
+
def perform_work(*args)
refresh = Projects::RefreshBuildArtifactsSizeStatisticsService.new.execute
return unless refresh
@@ -33,8 +37,12 @@ module Projects
end
def max_running_jobs
- if ::Feature.enabled?(:projects_build_artifacts_size_refresh, type: :ops)
- 10
+ if ::Feature.enabled?(:projects_build_artifacts_size_refresh_high, type: :ops)
+ MAX_RUNNING_HIGH
+ elsif ::Feature.enabled?(:projects_build_artifacts_size_refresh_medium, type: :ops)
+ MAX_RUNNING_MEDIUM
+ elsif ::Feature.enabled?(:projects_build_artifacts_size_refresh, type: :ops)
+ MAX_RUNNING_LOW
else
0
end
diff --git a/app/workers/remove_unreferenced_lfs_objects_worker.rb b/app/workers/remove_unreferenced_lfs_objects_worker.rb
index c67ab6e356a..52e17b250c8 100644
--- a/app/workers/remove_unreferenced_lfs_objects_worker.rb
+++ b/app/workers/remove_unreferenced_lfs_objects_worker.rb
@@ -10,7 +10,7 @@ class RemoveUnreferencedLfsObjectsWorker
include CronjobQueue
# rubocop:enable Scalability/CronWorkerContext
- feature_category :git_lfs
+ feature_category :source_code_management
deduplicate :until_executed
idempotent!
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index 5e89b9f3362..f9e12c5135a 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -18,9 +18,8 @@ class RepositoryImportWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options memory_killer_max_memory_growth_kb: ENV.fetch('MEMORY_KILLER_REPOSITORY_IMPORT_WORKER_MAX_MEMORY_GROWTH_KB', 300_000).to_i
def perform(project_id)
- @project = Project.find(project_id)
-
- return unless start_import
+ @project = Project.find_by_id(project_id)
+ return if project.nil? || !start_import?
Gitlab::Metrics.add_event(:import_repository)
@@ -42,7 +41,7 @@ class RepositoryImportWorker # rubocop:disable Scalability/IdempotentWorker
attr_reader :project
- def start_import
+ def start_import?
return true if start(project.import_state)
Gitlab::Import::Logger.info(
diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb
index f31f006eec1..db82cf3af91 100644
--- a/app/workers/run_pipeline_schedule_worker.rb
+++ b/app/workers/run_pipeline_schedule_worker.rb
@@ -13,12 +13,18 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker
deduplicate :until_executed
idempotent!
- def perform(schedule_id, user_id)
+ def perform(schedule_id, user_id, options = {})
schedule = Ci::PipelineSchedule.find_by_id(schedule_id)
user = User.find_by_id(user_id)
return unless schedule && schedule.project && user
+ if Feature.enabled?(:ci_use_run_pipeline_schedule_worker)
+ return if schedule.next_run_at > Time.current
+
+ update_next_run_at_for(schedule)
+ end
+
run_pipeline_schedule(schedule, user)
end
@@ -37,6 +43,12 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker
private
+ def update_next_run_at_for(schedule)
+ # Ensure `next_run_at` is set properly before creating a pipeline.
+ # Otherwise, multiple pipelines could be created in a short interval.
+ schedule.schedule_next_run!
+ end
+
def error(schedule, error)
failed_creation_counter.increment
log_error(schedule, error)
diff --git a/app/workers/schedule_merge_request_cleanup_refs_worker.rb b/app/workers/schedule_merge_request_cleanup_refs_worker.rb
index 8099c3d56b6..ced1f443ea6 100644
--- a/app/workers/schedule_merge_request_cleanup_refs_worker.rb
+++ b/app/workers/schedule_merge_request_cleanup_refs_worker.rb
@@ -7,7 +7,7 @@ class ScheduleMergeRequestCleanupRefsWorker
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
- feature_category :code_review
+ feature_category :code_review_workflow
idempotent!
def perform
diff --git a/app/workers/schedule_migrate_external_diffs_worker.rb b/app/workers/schedule_migrate_external_diffs_worker.rb
index b2dea5083b4..8f643cf7819 100644
--- a/app/workers/schedule_migrate_external_diffs_worker.rb
+++ b/app/workers/schedule_migrate_external_diffs_worker.rb
@@ -13,7 +13,7 @@ class ScheduleMigrateExternalDiffsWorker # rubocop:disable Scalability/Idempoten
include Gitlab::ExclusiveLeaseHelpers
- feature_category :code_review
+ feature_category :code_review_workflow
def perform
in_lock(self.class.name.underscore, ttl: 2.hours, retries: 0) do
diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb
index 3a650fcb7bf..44f2d5ade58 100644
--- a/app/workers/stuck_merge_jobs_worker.rb
+++ b/app/workers/stuck_merge_jobs_worker.rb
@@ -7,7 +7,7 @@ class StuckMergeJobsWorker # rubocop:disable Scalability/IdempotentWorker
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
- feature_category :code_review
+ feature_category :code_review_workflow
def self.logger
Gitlab::AppLogger
diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb
index eb69c0eaba6..caf46c1ac4e 100644
--- a/app/workers/update_merge_requests_worker.rb
+++ b/app/workers/update_merge_requests_worker.rb
@@ -7,7 +7,7 @@ class UpdateMergeRequestsWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: 3
- feature_category :code_review
+ feature_category :code_review_workflow
urgency :high
worker_resource_boundary :cpu
weight 3
diff --git a/app/workers/wait_for_cluster_creation_worker.rb b/app/workers/wait_for_cluster_creation_worker.rb
index af351c3c207..a34f5386363 100644
--- a/app/workers/wait_for_cluster_creation_worker.rb
+++ b/app/workers/wait_for_cluster_creation_worker.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class WaitForClusterCreationWorker # rubocop:disable Scalability/IdempotentWorker
+class WaitForClusterCreationWorker
include ApplicationWorker
data_consistency :always
@@ -8,17 +8,7 @@ class WaitForClusterCreationWorker # rubocop:disable Scalability/IdempotentWorke
sidekiq_options retry: 3
include ClusterQueue
- worker_has_external_dependencies!
+ idempotent!
- def perform(cluster_id)
- Clusters::Cluster.find_by_id(cluster_id).try do |cluster|
- cluster.provider.try do |provider|
- if cluster.gcp?
- Clusters::Gcp::VerifyProvisionStatusService.new.execute(provider)
- elsif cluster.aws?
- Clusters::Aws::VerifyProvisionStatusService.new.execute(provider)
- end
- end
- end
- end
+ def perform(_); end
end
diff --git a/app/workers/wikis/git_garbage_collect_worker.rb b/app/workers/wikis/git_garbage_collect_worker.rb
index 977493834c9..ab702653989 100644
--- a/app/workers/wikis/git_garbage_collect_worker.rb
+++ b/app/workers/wikis/git_garbage_collect_worker.rb
@@ -7,12 +7,6 @@ module Wikis
private
- # Used for getting a project/group out of the resource in order to scope a feature flag
- # Can be removed within https://gitlab.com/gitlab-org/gitlab/-/issues/353607
- def container(resource)
- resource.container
- end
-
override :find_resource
def find_resource(id)
Project.find(id).wiki