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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/callouts/rich_text_editor_illustration.svg79
-rw-r--r--app/assets/images/service_desk_callout.svg1
-rw-r--r--app/assets/javascripts/access_tokens/components/access_token_table_app.vue3
-rw-r--r--app/assets/javascripts/access_tokens/components/token.vue35
-rw-r--r--app/assets/javascripts/access_tokens/components/tokens_app.vue16
-rw-r--r--app/assets/javascripts/actioncable_connection_monitor.js142
-rw-r--r--app/assets/javascripts/actioncable_consumer.js9
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_category.vue33
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue17
-rw-r--r--app/assets/javascripts/admin/abuse_reports/constants.js45
-rw-r--r--app/assets/javascripts/admin/applications/components/delete_application.vue2
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/message_form.vue69
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/constants.js21
-rw-r--r--app/assets/javascripts/admin/users/components/actions/ban.vue2
-rw-r--r--app/assets/javascripts/admin/users/components/users_table.vue2
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue63
-rw-r--r--app/assets/javascripts/alerts_settings/constants.js6
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql1
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql6
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue2
-rw-r--r--app/assets/javascripts/analytics/shared/constants.js4
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/users_chart.vue12
-rw-r--r--app/assets/javascripts/api/groups_api.js4
-rw-r--r--app/assets/javascripts/api/user_api.js11
-rw-r--r--app/assets/javascripts/batch_comments/components/submit_dropdown.vue83
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js26
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji.js11
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js10
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_metrics.js47
-rw-r--r--app/assets/javascripts/behaviors/preview_markdown.js4
-rw-r--r--app/assets/javascripts/blob/line_highlighter.js61
-rw-r--r--app/assets/javascripts/boards/components/board_app.vue14
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue22
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue12
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue10
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue82
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue102
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue83
-rw-r--r--app/assets/javascripts/boards/components/issue_board_filtered_search.vue2
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue119
-rw-r--r--app/assets/javascripts/boards/constants.js26
-rw-r--r--app/assets/javascripts/boards/graphql/cache_updates.js38
-rw-r--r--app/assets/javascripts/boards/graphql/client/error.query.graphql3
-rw-r--r--app/assets/javascripts/boards/graphql/client/set_error.mutation.graphql3
-rw-r--r--app/assets/javascripts/boards/graphql/issue_create.mutation.graphql4
-rw-r--r--app/assets/javascripts/boards/stores/actions.js4
-rw-r--r--app/assets/javascripts/branches/components/delete_merged_branches.vue7
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue39
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue16
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue7
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue5
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue25
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_environments.query.graphql10
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue3
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue9
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue25
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue15
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue309
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue15
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/constants.js5
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/create_pipeline_schedule.mutation.graphql6
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/update_pipeline_schedule.mutation.graphql6
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql20
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js4
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js9
-rw-r--r--app/assets/javascripts/ci/reports/components/grouped_issues_list.vue106
-rw-r--r--app/assets/javascripts/ci/reports/components/summary_row.vue93
-rw-r--r--app/assets/javascripts/ci/reports/constants.js5
-rw-r--r--app/assets/javascripts/ci/reports/sast/constants.js44
-rw-r--r--app/assets/javascripts/ci/reports/utils.js20
-rw-r--r--app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue16
-rw-r--r--app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue8
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue18
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue106
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_token.vue1
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue34
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_delete_action.vue126
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_delete_button.vue126
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_delete_disclosure_dropdown_item.vue38
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_delete_modal.vue3
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_detail.vue4
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_edit_button.vue10
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_edit_disclosure_dropdown_item.vue29
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_header.vue46
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_header_actions.vue80
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue54
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_pause_action.vue89
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_pause_button.vue97
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_pause_disclosure_dropdown_item.vue34
-rw-r--r--app/assets/javascripts/ci/runner/constants.js21
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql6
-rw-r--r--app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue16
-rw-r--r--app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue12
-rw-r--r--app/assets/javascripts/clusters/forms/show/index.js3
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_actions.vue81
-rw-r--r--app/assets/javascripts/comment_templates/components/app.vue30
-rw-r--r--app/assets/javascripts/comment_templates/components/form.vue2
-rw-r--r--app/assets/javascripts/comment_templates/components/list.vue16
-rw-r--r--app/assets/javascripts/comment_templates/components/list_item.vue2
-rw-r--r--app/assets/javascripts/comment_templates/pages/index.vue10
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue57
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue69
-rw-r--r--app/assets/javascripts/content_editor/components/formatting_toolbar.vue259
-rw-r--r--app/assets/javascripts/content_editor/components/suggestions_dropdown.vue146
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_button.vue7
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue4
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_table_button.vue82
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/code_block.vue207
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/image.vue103
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/reference.vue15
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_suggestion.js81
-rw-r--r--app/assets/javascripts/content_editor/extensions/comment.js49
-rw-r--r--app/assets/javascripts/content_editor/extensions/copy_paste.js (renamed from app/assets/javascripts/content_editor/extensions/paste_markdown.js)62
-rw-r--r--app/assets/javascripts/content_editor/extensions/hard_break.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/loading.js23
-rw-r--r--app/assets/javascripts/content_editor/extensions/paragraph.js10
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference.js6
-rw-r--r--app/assets/javascripts/content_editor/services/code_suggestion_utils.js32
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js14
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js17
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js7
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js7
-rw-r--r--app/assets/javascripts/content_editor/services/utils.js8
-rw-r--r--app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_approved.vue21
-rw-r--r--app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue29
-rw-r--r--app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_expired.vue46
-rw-r--r--app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_joined.vue44
-rw-r--r--app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_left.vue44
-rw-r--r--app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_merged.vue29
-rw-r--r--app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_private.vue24
-rw-r--r--app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_pushed.vue110
-rw-r--r--app/assets/javascripts/contribution_events/components/contribution_events.vue34
-rw-r--r--app/assets/javascripts/contribution_events/components/resource_parent_link.vue4
-rw-r--r--app/assets/javascripts/contribution_events/components/target_link.vue2
-rw-r--r--app/assets/javascripts/contribution_events/constants.js4
-rw-r--r--app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue2
-rw-r--r--app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue2
-rw-r--r--app/assets/javascripts/deprecated_notes.js14
-rw-r--r--app/assets/javascripts/design_management/components/design_description/description_form.vue9
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue6
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_note.vue153
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_note_awards_list.vue34
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue2
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql9
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql1
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/design_note_award_emoji_toggle.mutation.graphql6
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue22
-rw-r--r--app/assets/javascripts/diffs/components/app.vue33
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_code_quality.vue38
-rw-r--r--app/assets/javascripts/diffs/components/diff_code_quality_item.vue30
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue32
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue79
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_inline_findings.vue32
-rw-r--r--app/assets/javascripts/diffs/components/diff_line.vue14
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue24
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue46
-rw-r--r--app/assets/javascripts/diffs/components/diff_row_utils.js4
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue11
-rw-r--r--app/assets/javascripts/diffs/components/pre_renderer.vue83
-rw-r--r--app/assets/javascripts/diffs/components/settings_dropdown.vue162
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue4
-rw-r--r--app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js13
-rw-r--r--app/assets/javascripts/diffs/i18n.js16
-rw-r--r--app/assets/javascripts/diffs/index.js2
-rw-r--r--app/assets/javascripts/diffs/store/actions.js83
-rw-r--r--app/assets/javascripts/diffs/store/getters.js5
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js11
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js16
-rw-r--r--app/assets/javascripts/diffs/store/utils.js2
-rw-r--r--app/assets/javascripts/emoji/components/category.vue5
-rw-r--r--app/assets/javascripts/emoji/components/picker.vue27
-rw-r--r--app/assets/javascripts/emoji/components/utils.js2
-rw-r--r--app/assets/javascripts/emoji/constants.js1
-rw-r--r--app/assets/javascripts/emoji/index.js63
-rw-r--r--app/assets/javascripts/emoji/queries/custom_emoji.query.graphql12
-rw-r--r--app/assets/javascripts/environments/components/edit_environment.vue75
-rw-r--r--app/assets/javascripts/environments/components/environment_form.vue137
-rw-r--r--app/assets/javascripts/environments/components/new_environment.vue28
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue26
-rw-r--r--app/assets/javascripts/environments/components/stop_environment_modal.vue15
-rw-r--r--app/assets/javascripts/environments/constants.js16
-rw-r--r--app/assets/javascripts/environments/edit.js14
-rw-r--r--app/assets/javascripts/environments/graphql/client.js9
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_namespace.query.graphql20
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_with_namespace.graphql15
-rw-r--r--app/assets/javascripts/environments/graphql/queries/k8s_namespaces.query.graphql7
-rw-r--r--app/assets/javascripts/environments/graphql/resolvers.js25
-rw-r--r--app/assets/javascripts/environments/graphql/typedefs.graphql6
-rw-r--r--app/assets/javascripts/environments/helpers/k8s_integration_helper.js7
-rw-r--r--app/assets/javascripts/environments/new.js9
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue91
-rw-r--r--app/assets/javascripts/error_tracking/components/timeline_chart.vue4
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_table.vue12
-rw-r--r--app/assets/javascripts/frequent_items/store/mutations.js2
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js2
-rw-r--r--app/assets/javascripts/gitlab_version_check/constants.js3
-rw-r--r--app/assets/javascripts/google_cloud/service_accounts/list.vue5
-rw-r--r--app/assets/javascripts/google_tag_manager/index.js3
-rw-r--r--app/assets/javascripts/graphql_shared/issuable_client.js43
-rw-r--r--app/assets/javascripts/groups/components/app.vue9
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue6
-rw-r--r--app/assets/javascripts/groups/components/overview_tabs.vue73
-rw-r--r--app/assets/javascripts/groups/constants.js54
-rw-r--r--app/assets/javascripts/groups/init_overview_tabs.js2
-rw-r--r--app/assets/javascripts/groups/service/archived_projects_service.js56
-rw-r--r--app/assets/javascripts/groups/service/groups_service.js13
-rw-r--r--app/assets/javascripts/groups/settings/init_access_dropdown.js2
-rw-r--r--app/assets/javascripts/header_search/components/app.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue1
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue1
-rw-r--r--app/assets/javascripts/invite_members/components/group_select.vue151
-rw-r--r--app/assets/javascripts/invite_members/components/import_project_members_modal.vue1
-rw-r--r--app/assets/javascripts/invite_members/components/invite_groups_modal.vue12
-rw-r--r--app/assets/javascripts/invite_members/components/members_token_select.vue6
-rw-r--r--app/assets/javascripts/issuable/components/status_box.vue43
-rw-r--r--app/assets/javascripts/issuable/issuable_form.js15
-rw-r--r--app/assets/javascripts/issuable/issuable_label_selector.js1
-rw-r--r--app/assets/javascripts/issuable/popover/components/issue_popover.vue8
-rw-r--r--app/assets/javascripts/issuable/popover/components/mr_popover.vue8
-rw-r--r--app/assets/javascripts/issuable/popover/index.js20
-rw-r--r--app/assets/javascripts/issues/constants.js3
-rw-r--r--app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue7
-rw-r--r--app/assets/javascripts/issues/dashboard/index.js2
-rw-r--r--app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue2
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue4
-rw-r--r--app/assets/javascripts/issues/list/index.js2
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue94
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description.vue49
-rw-r--r--app/assets/javascripts/issues/show/components/form.vue13
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue42
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue42
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue2
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/app.vue68
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/feedback_banner.vue57
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job/job_app.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job/job_log_controllers.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue34
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue7
-rw-r--r--app/assets/javascripts/jobs/components/job/stuck_block.vue3
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/job_cell.vue4
-rw-r--r--app/assets/javascripts/jobs/index.js3
-rw-r--r--app/assets/javascripts/layout_nav.js18
-rw-r--r--app/assets/javascripts/lib/logger/hello.js5
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js10
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue3
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_format_utility.js32
-rw-r--r--app/assets/javascripts/lib/utils/forms.js57
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js6
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/vue_router.js30
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue9
-rw-r--r--app/assets/javascripts/members/components/table/role_dropdown.vue1
-rw-r--r--app/assets/javascripts/members/constants.js14
-rw-r--r--app/assets/javascripts/merge_request_tabs.js8
-rw-r--r--app/assets/javascripts/merge_requests/components/sticky_header.vue6
-rw-r--r--app/assets/javascripts/merge_requests/generated_content.js64
-rw-r--r--app/assets/javascripts/milestones/index.js19
-rw-r--r--app/assets/javascripts/ml/model_registry/routes/models/index/components/ml_models_index.vue34
-rw-r--r--app/assets/javascripts/ml/model_registry/routes/models/index/index.js3
-rw-r--r--app/assets/javascripts/ml/model_registry/routes/models/index/translations.js3
-rw-r--r--app/assets/javascripts/monitoring/components/charts/annotations.js133
-rw-r--r--app/assets/javascripts/monitoring/components/charts/anomaly.vue230
-rw-r--r--app/assets/javascripts/monitoring/components/charts/bar.vue87
-rw-r--r--app/assets/javascripts/monitoring/components/charts/column.vue107
-rw-r--r--app/assets/javascripts/monitoring/components/charts/empty_chart.vue37
-rw-r--r--app/assets/javascripts/monitoring/components/charts/gauge.vue110
-rw-r--r--app/assets/javascripts/monitoring/components/charts/heatmap.vue74
-rw-r--r--app/assets/javascripts/monitoring/components/charts/options.js175
-rw-r--r--app/assets/javascripts/monitoring/components/charts/single_stat.vue71
-rw-r--r--app/assets/javascripts/monitoring/components/charts/stacked_column.vue146
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue420
-rw-r--r--app/assets/javascripts/monitoring/components/create_dashboard_modal.vue66
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue510
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue291
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue294
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel.vue388
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue204
-rw-r--r--app/assets/javascripts/monitoring/components/dashboards_dropdown.vue127
-rw-r--r--app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue138
-rw-r--r--app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue106
-rw-r--r--app/assets/javascripts/monitoring/components/embeds/embed_group.vue102
-rw-r--r--app/assets/javascripts/monitoring/components/embeds/metric_embed.vue125
-rw-r--r--app/assets/javascripts/monitoring/components/empty_state.vue114
-rw-r--r--app/assets/javascripts/monitoring/components/graph_group.vue87
-rw-r--r--app/assets/javascripts/monitoring/components/group_empty_state.vue109
-rw-r--r--app/assets/javascripts/monitoring/components/links_section.vue32
-rw-r--r--app/assets/javascripts/monitoring/components/refresh_button.vue168
-rw-r--r--app/assets/javascripts/monitoring/components/variables/dropdown_field.vue53
-rw-r--r--app/assets/javascripts/monitoring/components/variables/text_field.vue39
-rw-r--r--app/assets/javascripts/monitoring/components/variables_section.vue53
-rw-r--r--app/assets/javascripts/monitoring/constants.js262
-rw-r--r--app/assets/javascripts/monitoring/csv_export.js146
-rw-r--r--app/assets/javascripts/monitoring/format_date.js40
-rw-r--r--app/assets/javascripts/monitoring/monitoring_app.js49
-rw-r--r--app/assets/javascripts/monitoring/monitoring_tracking_helper.js10
-rw-r--r--app/assets/javascripts/monitoring/pages/dashboard_page.vue29
-rw-r--r--app/assets/javascripts/monitoring/pages/panel_new_page.vue45
-rw-r--r--app/assets/javascripts/monitoring/queries/get_annotations.query.graphql27
-rw-r--r--app/assets/javascripts/monitoring/queries/get_dashboard_validation_warnings.query.graphql19
-rw-r--r--app/assets/javascripts/monitoring/queries/get_environments.query.graphql11
-rw-r--r--app/assets/javascripts/monitoring/requests/index.js51
-rw-r--r--app/assets/javascripts/monitoring/router/constants.js7
-rw-r--r--app/assets/javascripts/monitoring/router/index.js15
-rw-r--r--app/assets/javascripts/monitoring/router/routes.js24
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js576
-rw-r--r--app/assets/javascripts/monitoring/stores/embed_group/actions.js3
-rw-r--r--app/assets/javascripts/monitoring/stores/embed_group/getters.js2
-rw-r--r--app/assets/javascripts/monitoring/stores/embed_group/index.js22
-rw-r--r--app/assets/javascripts/monitoring/stores/embed_group/mutation_types.js1
-rw-r--r--app/assets/javascripts/monitoring/stores/embed_group/mutations.js7
-rw-r--r--app/assets/javascripts/monitoring/stores/embed_group/state.js3
-rw-r--r--app/assets/javascripts/monitoring/stores/getters.js174
-rw-r--r--app/assets/javascripts/monitoring/stores/index.js29
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js62
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js273
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js97
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js505
-rw-r--r--app/assets/javascripts/monitoring/stores/variable_mapping.js273
-rw-r--r--app/assets/javascripts/monitoring/utils.js402
-rw-r--r--app/assets/javascripts/monitoring/validators.js55
-rw-r--r--app/assets/javascripts/mr_more_dropdown.js9
-rw-r--r--app/assets/javascripts/mr_notes/init.js7
-rw-r--r--app/assets/javascripts/nav/components/new_nav_toggle.vue9
-rw-r--r--app/assets/javascripts/nav/components/responsive_home.vue2
-rw-r--r--app/assets/javascripts/nav/components/top_nav_app.vue2
-rw-r--r--app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue1
-rw-r--r--app/assets/javascripts/notes/components/comment_field_layout.vue20
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue14
-rw-r--r--app/assets/javascripts/notes/components/comment_type_dropdown.vue97
-rw-r--r--app/assets/javascripts/notes/components/diff_discussion_header.vue8
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue90
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue1
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue2
-rw-r--r--app/assets/javascripts/notes/components/mr_discussion_filter.vue11
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue29
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue1
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue34
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue19
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue34
-rw-r--r--app/assets/javascripts/notes/components/toggle_replies_widget.vue2
-rw-r--r--app/assets/javascripts/notes/mixins/diff_line_note_form.js34
-rw-r--r--app/assets/javascripts/notes/utils.js14
-rw-r--r--app/assets/javascripts/notifications/components/notification_email_listbox_input.vue4
-rw-r--r--app/assets/javascripts/notifications/index.js10
-rw-r--r--app/assets/javascripts/observability/client.js43
-rw-r--r--app/assets/javascripts/observability/components/observability_container.vue92
-rw-r--r--app/assets/javascripts/observability/components/skeleton/index.vue59
-rw-r--r--app/assets/javascripts/observability/constants.js1
-rw-r--r--app/assets/javascripts/observability/mock_traces.json2807
-rw-r--r--app/assets/javascripts/organizations/groups_and_projects/components/app.vue62
-rw-r--r--app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql24
-rw-r--r--app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js14
-rw-r--r--app/assets/javascripts/organizations/groups_and_projects/index.js24
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue42
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue29
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_empty_state.vue80
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue21
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js9
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue277
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue31
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue53
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql14
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/index.js3
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue25
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue18
-rw-r--r--app/assets/javascripts/pages/groups/new/components/app.vue8
-rw-r--r--app/assets/javascripts/pages/organizations/organizations/groups_and_projects/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/two_factor_auths/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/environments/metrics/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue1
-rw-r--r--app/assets/javascripts/pages/projects/issues/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/service_desk/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/diffs/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/page.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/metrics_dashboard/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/ml/models/index/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue7
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/shared/web_ide_link/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/shared/web_ide_link/provide_web_ide_link.js9
-rw-r--r--app/assets/javascripts/pages/projects/tracing/index/index.js4
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue7
-rw-r--r--app/assets/javascripts/pages/shared/wikis/constants.js1
-rw-r--r--app/assets/javascripts/pages/shared/wikis/edit.js7
-rw-r--r--app/assets/javascripts/pages/users/show/index.js2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue41
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue320
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_details_header.vue52
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ios_templates.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue (renamed from app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue)71
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue166
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue108
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue51
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue13
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/empty_state.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue2
-rw-r--r--app/assets/javascripts/pipelines/graphql/mutations/retry_mr_failed_job.mutation.graphql5
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql7
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs_count.query.graphql12
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js12
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_header.js35
-rw-r--r--app/assets/javascripts/profile/account/components/delete_account_modal.vue16
-rw-r--r--app/assets/javascripts/profile/account/components/update_username.vue2
-rw-r--r--app/assets/javascripts/profile/components/follow.vue33
-rw-r--r--app/assets/javascripts/profile/components/followers_tab.vue4
-rw-r--r--app/assets/javascripts/profile/components/following_tab.vue57
-rw-r--r--app/assets/javascripts/profile/components/profile_tabs.vue17
-rw-r--r--app/assets/javascripts/profile/components/snippets/snippets_tab.vue37
-rw-r--r--app/assets/javascripts/profile/components/user_achievements.vue2
-rw-r--r--app/assets/javascripts/profile/index.js4
-rw-r--r--app/assets/javascripts/profile/preferences/components/profile_preferences.vue49
-rw-r--r--app/assets/javascripts/projects/clusters_deprecation_alert/components/clusters_deprecation_alert.vue4
-rw-r--r--app/assets/javascripts/projects/commits/components/author_select.vue136
-rw-r--r--app/assets/javascripts/projects/compare/components/app.vue126
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_card.vue2
-rw-r--r--app/assets/javascripts/projects/compare/constants.js25
-rw-r--r--app/assets/javascripts/projects/new/components/app.vue16
-rw-r--r--app/assets/javascripts/projects/settings/access_dropdown.js16
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue18
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue22
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/index.js8
-rw-r--r--app/assets/javascripts/projects/tree/components/commit_pipeline_status.vue (renamed from app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue)0
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue79
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue3
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue34
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/geo_json/constants.js2
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/index.js8
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue177
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue2
-rw-r--r--app/assets/javascripts/repository/index.js8
-rw-r--r--app/assets/javascripts/repository/mixins/highlight_mixin.js13
-rw-r--r--app/assets/javascripts/search/sidebar/components/label_filter/index.vue31
-rw-r--r--app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue6
-rw-r--r--app/assets/javascripts/search/store/constants.js6
-rw-r--r--app/assets/javascripts/search/topbar/components/group_filter.vue3
-rw-r--r--app/assets/javascripts/search/topbar/components/project_filter.vue3
-rw-r--r--app/assets/javascripts/security_configuration/components/training_provider_list.vue2
-rw-r--r--app/assets/javascripts/service_desk/components/info_banner.vue64
-rw-r--r--app/assets/javascripts/service_desk/components/service_desk_list_app.vue151
-rw-r--r--app/assets/javascripts/service_desk/constants.js17
-rw-r--r--app/assets/javascripts/service_desk/graphql.js24
-rw-r--r--app/assets/javascripts/service_desk/index.js55
-rw-r--r--app/assets/javascripts/service_desk/queries/get_service_desk_issues.query.graphql72
-rw-r--r--app/assets/javascripts/service_desk/queries/get_service_desk_issues_counts.query.graphql91
-rw-r--r--app/assets/javascripts/service_desk/queries/issue.fragment.graphql60
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue76
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.vue20
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue9
-rw-r--r--app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue15
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue9
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue35
-rw-r--r--app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue36
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js1
-rw-r--r--app/assets/javascripts/snippets/constants.js3
-rw-r--r--app/assets/javascripts/super_sidebar/components/brand_logo.vue2
-rw-r--r--app/assets/javascripts/super_sidebar/components/create_menu.vue17
-rw-r--r--app/assets/javascripts/super_sidebar/components/frequent_items_list.vue1
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue154
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js13
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue4
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js14
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue106
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/constants.js2
-rw-r--r--app/assets/javascripts/super_sidebar/components/help_center.vue2
-rw-r--r--app/assets/javascripts/super_sidebar/components/items_list.vue9
-rw-r--r--app/assets/javascripts/super_sidebar/components/menu_section.vue10
-rw-r--r--app/assets/javascripts/super_sidebar/components/nav_item.vue18
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue6
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar.vue6
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue6
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_bar.vue6
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_menu.vue23
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_name_group.vue6
-rw-r--r--app/assets/javascripts/super_sidebar/constants.js2
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_bundle.js14
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js9
-rw-r--r--app/assets/javascripts/surveys/merge_request_experience/app.vue4
-rw-r--r--app/assets/javascripts/token_access/components/outbound_token_access.vue24
-rw-r--r--app/assets/javascripts/tracing/components/tracing_empty_state.vue46
-rw-r--r--app/assets/javascripts/tracing/components/tracing_list.vue93
-rw-r--r--app/assets/javascripts/tracing/components/tracing_table_list.vue89
-rw-r--r--app/assets/javascripts/tracing/list_index.vue37
-rw-r--r--app/assets/javascripts/tracking/constants.js5
-rw-r--r--app/assets/javascripts/tracking/index.js4
-rw-r--r--app/assets/javascripts/tracking/internal_events.js58
-rw-r--r--app/assets/javascripts/tracking/utils.js18
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue7
-rw-r--r--app/assets/javascripts/usage_quotas/storage/constants.js17
-rw-r--r--app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql1
-rw-r--r--app/assets/javascripts/users/profile/actions/components/user_actions_app.vue45
-rw-r--r--app/assets/javascripts/users/profile/actions/index.js25
-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/extensions/index.js83
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue41
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue103
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue2
-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/dynamic_content.vue32
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue37
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.vue (renamed from app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js)77
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue31
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/actions_button.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue17
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/code_block_highlighted.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/constants.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/group_select.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/project_select.vue1
-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.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue26
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue103
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue59
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue163
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js17
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/non_gfm_markdown.stories.js89
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/non_gfm_markdown.vue120
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue208
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/tracking.js14
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/utils.js7
-rw-r--r--app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue154
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/list_item.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_lines.js20
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_worker.js (renamed from app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight.js)0
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue25
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue7
-rw-r--r--app/assets/javascripts/vue_shared/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/global_search/constants.js20
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue40
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue7
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue11
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue8
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue91
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/constants.js8
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue58
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue59
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue238
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/constants.js7
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/getters.js66
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/index.js16
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/messages.js4
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js26
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js10
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js4
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js31
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js14
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js26
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js10
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js4
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js30
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js14
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/state.js5
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/utils.js154
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/utils.js10
-rw-r--r--app/assets/javascripts/work_items/components/item_state.vue4
-rw-r--r--app/assets/javascripts/work_items/components/notes/system_note.vue2
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note.vue21
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue64
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue95
-rw-r--r--app/assets/javascripts/work_items/components/widget_wrapper.vue28
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees.vue7
-rw-r--r--app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue180
-rw-r--r--app/assets/javascripts/work_items/components/work_item_award_emoji.vue90
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue12
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description_rendered.vue2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue403
-rw-r--r--app/assets/javascripts/work_items/components/work_item_due_date.vue6
-rw-r--r--app/assets/javascripts/work_items/components/work_item_labels.vue14
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue100
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue3
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue8
-rw-r--r--app/assets/javascripts/work_items/components/work_item_milestone.vue6
-rw-r--r--app/assets/javascripts/work_items/components/work_item_todos.vue109
-rw-r--r--app/assets/javascripts/work_items/constants.js15
-rw-r--r--app/assets/javascripts/work_items/graphql/award_emoji.query.graphql27
-rw-r--r--app/assets/javascripts/work_items/graphql/cache_utils.js40
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item_todos.mutation.graphql9
-rw-r--r--app/assets/javascripts/work_items/graphql/mark_done_work_item_todos.mutation.graphql9
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql6
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql12
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_note_remove_award_emoji.mutation.graphql5
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_assignees.subscription.graphql21
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_description.subscription.graphql20
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_labels.subscription.graphql19
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql13
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_milestone.subscription.graphql17
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_title.subscription.graphql8
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_updated.subscription.graphql7
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql13
-rw-r--r--app/assets/javascripts/work_items/notes/award_utils.js67
-rw-r--r--app/assets/javascripts/work_items/utils.js49
-rw-r--r--app/assets/stylesheets/components/avatar.scss21
-rw-r--r--app/assets/stylesheets/components/content_editor.scss175
-rw-r--r--app/assets/stylesheets/fonts.scss54
-rw-r--r--app/assets/stylesheets/framework.scss2
-rw-r--r--app/assets/stylesheets/framework/brand_logo.scss29
-rw-r--r--app/assets/stylesheets/framework/common.scss33
-rw-r--r--app/assets/stylesheets/framework/diffs.scss22
-rw-r--r--app/assets/stylesheets/framework/emojis.scss7
-rw-r--r--app/assets/stylesheets/framework/header.scss4
-rw-r--r--app/assets/stylesheets/framework/layout.scss7
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss3
-rw-r--r--app/assets/stylesheets/framework/new_card.scss94
-rw-r--r--app/assets/stylesheets/framework/selects.scss6
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss4
-rw-r--r--app/assets/stylesheets/framework/super_sidebar.scss35
-rw-r--r--app/assets/stylesheets/framework/system_messages.scss10
-rw-r--r--app/assets/stylesheets/framework/timeline.scss8
-rw-r--r--app/assets/stylesheets/framework/typography.scss11
-rw-r--r--app/assets/stylesheets/framework/variables.scss15
-rw-r--r--app/assets/stylesheets/page_bundles/branches.scss12
-rw-r--r--app/assets/stylesheets/page_bundles/design_management.scss17
-rw-r--r--app/assets/stylesheets/page_bundles/issuable.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/jira_connect.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/login.scss62
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss25
-rw-r--r--app/assets/stylesheets/page_bundles/notifications.scss28
-rw-r--r--app/assets/stylesheets/page_bundles/profiles/preferences.scss7
-rw-r--r--app/assets/stylesheets/page_bundles/project.scss6
-rw-r--r--app/assets/stylesheets/page_bundles/prometheus.scss113
-rw-r--r--app/assets/stylesheets/page_bundles/search.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/settings.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/tree.scss15
-rw-r--r--app/assets/stylesheets/page_bundles/work_items.scss54
-rw-r--r--app/assets/stylesheets/pages/commits.scss8
-rw-r--r--app/assets/stylesheets/pages/note_form.scss76
-rw-r--r--app/assets/stylesheets/pages/notes.scss20
-rw-r--r--app/assets/stylesheets/pages/projects.scss54
-rw-r--r--app/assets/stylesheets/pages/settings.scss67
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss20
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss20
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss39
-rw-r--r--app/assets/stylesheets/themes/dark_mode_overrides.scss3
-rw-r--r--app/assets/stylesheets/utilities.scss20
-rw-r--r--app/assets/stylesheets/vendors/atwho.scss29
-rw-r--r--app/components/pajamas/banner_component.html.haml2
-rw-r--r--app/components/pajamas/banner_component.rb2
-rw-r--r--app/components/pajamas/empty_state_component.html.haml29
-rw-r--r--app/components/pajamas/empty_state_component.rb35
-rw-r--r--app/controllers/admin/application_settings/appearances_controller.rb2
-rw-r--r--app/controllers/admin/application_settings_controller.rb10
-rw-r--r--app/controllers/admin/runners_controller.rb19
-rw-r--r--app/controllers/admin/users_controller.rb7
-rw-r--r--app/controllers/application_controller.rb2
-rw-r--r--app/controllers/clusters/clusters_controller.rb1
-rw-r--r--app/controllers/concerns/integrations/params.rb2
-rw-r--r--app/controllers/concerns/internal_redirect.rb2
-rw-r--r--app/controllers/concerns/issuable_actions.rb5
-rw-r--r--app/controllers/concerns/membership_actions.rb2
-rw-r--r--app/controllers/concerns/metrics_dashboard.rb126
-rw-r--r--app/controllers/concerns/observability/content_security_policy.rb21
-rw-r--r--app/controllers/concerns/onboarding/status.rb41
-rw-r--r--app/controllers/concerns/preview_markdown.rb4
-rw-r--r--app/controllers/concerns/redirects_for_missing_path_on_tree.rb4
-rw-r--r--app/controllers/concerns/requires_allowlisted_monitoring_client.rb (renamed from app/controllers/concerns/requires_whitelisted_monitoring_client.rb)16
-rw-r--r--app/controllers/concerns/uploads_actions.rb17
-rw-r--r--app/controllers/concerns/verifies_with_email.rb8
-rw-r--r--app/controllers/explore/projects_controller.rb9
-rw-r--r--app/controllers/graphql_controller.rb33
-rw-r--r--app/controllers/groups/milestones_controller.rb4
-rw-r--r--app/controllers/groups/runners_controller.rb20
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb1
-rw-r--r--app/controllers/groups/uploads_controller.rb2
-rw-r--r--app/controllers/health_check_controller.rb2
-rw-r--r--app/controllers/health_controller.rb2
-rw-r--r--app/controllers/import/base_controller.rb2
-rw-r--r--app/controllers/import/bitbucket_server_controller.rb4
-rw-r--r--app/controllers/metrics_controller.rb2
-rw-r--r--app/controllers/oauth/authorizations_controller.rb2
-rw-r--r--app/controllers/organizations/application_controller.rb2
-rw-r--r--app/controllers/organizations/organizations_controller.rb4
-rw-r--r--app/controllers/profiles/accounts_controller.rb1
-rw-r--r--app/controllers/profiles/notifications_controller.rb2
-rw-r--r--app/controllers/projects/alerting/notifications_controller.rb2
-rw-r--r--app/controllers/projects/blob_controller.rb15
-rw-r--r--app/controllers/projects/environments_controller.rb10
-rw-r--r--app/controllers/projects/forks_controller.rb11
-rw-r--r--app/controllers/projects/grafana_api_controller.rb46
-rw-r--r--app/controllers/projects/incidents_controller.rb1
-rw-r--r--app/controllers/projects/issues_controller.rb4
-rw-r--r--app/controllers/projects/jobs_controller.rb6
-rw-r--r--app/controllers/projects/merge_requests/conflicts_controller.rb14
-rw-r--r--app/controllers/projects/merge_requests/creations_controller.rb20
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb11
-rw-r--r--app/controllers/projects/merge_requests/drafts_controller.rb27
-rw-r--r--app/controllers/projects/merge_requests_controller.rb75
-rw-r--r--app/controllers/projects/milestones_controller.rb4
-rw-r--r--app/controllers/projects/ml/candidates_controller.rb10
-rw-r--r--app/controllers/projects/ml/experiments_controller.rb9
-rw-r--r--app/controllers/projects/ml/models_controller.rb20
-rw-r--r--app/controllers/projects/notes_controller.rb23
-rw-r--r--app/controllers/projects/pages_controller.rb10
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb25
-rw-r--r--app/controllers/projects/pipelines_controller.rb5
-rw-r--r--app/controllers/projects/runners_controller.rb16
-rw-r--r--app/controllers/projects/service_desk/custom_email_controller.rb84
-rw-r--r--app/controllers/projects/service_desk_controller.rb6
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb4
-rw-r--r--app/controllers/projects/tracing_controller.rb19
-rw-r--r--app/controllers/projects/tree_controller.rb35
-rw-r--r--app/controllers/projects/uploads_controller.rb2
-rw-r--r--app/controllers/projects_controller.rb6
-rw-r--r--app/controllers/registrations/welcome_controller.rb53
-rw-r--r--app/controllers/registrations_controller.rb3
-rw-r--r--app/controllers/uploads_controller.rb2
-rw-r--r--app/controllers/users_controller.rb45
-rw-r--r--app/experiments/concerns/project_commit_count.rb21
-rw-r--r--app/experiments/empty_repo_upload_experiment.rb22
-rw-r--r--app/experiments/force_company_trial_experiment.rb11
-rw-r--r--app/experiments/logged_out_marketing_header_experiment.rb9
-rw-r--r--app/finders/award_emojis_finder.rb2
-rw-r--r--app/finders/ci/group_variables_finder.rb39
-rw-r--r--app/finders/ci/pipelines_finder.rb2
-rw-r--r--app/finders/ci/runners_finder.rb11
-rw-r--r--app/finders/deployments_finder.rb1
-rw-r--r--app/finders/events_finder.rb1
-rw-r--r--app/finders/group_descendants_finder.rb6
-rw-r--r--app/finders/group_projects_finder.rb9
-rw-r--r--app/finders/issuable_finder.rb3
-rw-r--r--app/finders/issuables/assignee_filter.rb2
-rw-r--r--app/finders/members_finder.rb4
-rw-r--r--app/finders/merge_requests_finder.rb1
-rw-r--r--app/finders/packages/ml_model/package_finder.rb23
-rw-r--r--app/finders/packages/npm/package_finder.rb19
-rw-r--r--app/finders/projects/ml/model_finder.rb21
-rw-r--r--app/finders/projects_finder.rb10
-rw-r--r--app/finders/users_finder.rb20
-rw-r--r--app/graphql/gitlab_schema.rb3
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/create.rb2
-rw-r--r--app/graphql/mutations/ci/job_token_scope/add_project.rb5
-rw-r--r--app/graphql/mutations/ci/pipeline_schedule/create.rb30
-rw-r--r--app/graphql/mutations/ci/pipeline_schedule/update.rb14
-rw-r--r--app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb7
-rw-r--r--app/graphql/mutations/ci/project_ci_cd_settings_update.rb2
-rw-r--r--app/graphql/mutations/ci/runner/create.rb24
-rw-r--r--app/graphql/mutations/environments/create.rb5
-rw-r--r--app/graphql/mutations/environments/update.rb5
-rw-r--r--app/graphql/resolvers/alert_management/http_integrations_resolver.rb2
-rw-r--r--app/graphql/resolvers/alert_management/integrations_resolver.rb2
-rw-r--r--app/graphql/resolvers/ci/inherited_variables_resolver.rb8
-rw-r--r--app/graphql/resolvers/ci/runner_job_count_resolver.rb49
-rw-r--r--app/graphql/resolvers/ci/runners_resolver.rb3
-rw-r--r--app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb8
-rw-r--r--app/graphql/resolvers/concerns/resolves_merge_requests.rb5
-rw-r--r--app/graphql/resolvers/issues/base_resolver.rb8
-rw-r--r--app/graphql/resolvers/metrics/dashboard_resolver.rb35
-rw-r--r--app/graphql/resolvers/user_merge_requests_resolver_base.rb8
-rw-r--r--app/graphql/types/alert_management/alert_type.rb13
-rw-r--r--app/graphql/types/assignee_wildcard_id_enum.rb11
-rw-r--r--app/graphql/types/boards/assignee_wildcard_id_enum.rb13
-rw-r--r--app/graphql/types/boards/board_issue_input_type.rb4
-rw-r--r--app/graphql/types/ci/config/include_type.rb16
-rw-r--r--app/graphql/types/ci/group_variable_type.rb4
-rw-r--r--app/graphql/types/ci/group_variables_sort_enum.rb20
-rw-r--r--app/graphql/types/ci/job_type.rb10
-rw-r--r--app/graphql/types/ci/project_variable_type.rb4
-rw-r--r--app/graphql/types/ci/runner_sort_enum.rb2
-rw-r--r--app/graphql/types/ci/runner_type.rb49
-rw-r--r--app/graphql/types/ci/stage_type.rb2
-rw-r--r--app/graphql/types/current_user_todos.rb3
-rw-r--r--app/graphql/types/environment_type.rb8
-rw-r--r--app/graphql/types/ide_type.rb17
-rw-r--r--app/graphql/types/merge_request_type.rb4
-rw-r--r--app/graphql/types/metrics/dashboard_type.rb33
-rw-r--r--app/graphql/types/project_statistics_type.rb2
-rw-r--r--app/graphql/types/project_type.rb2
-rw-r--r--app/graphql/types/root_storage_statistics_type.rb14
-rw-r--r--app/graphql/types/user_interface.rb11
-rw-r--r--app/helpers/admin/application_settings/settings_helper.rb19
-rw-r--r--app/helpers/application_helper.rb20
-rw-r--r--app/helpers/application_settings_helper.rb3
-rw-r--r--app/helpers/auth_helper.rb3
-rw-r--r--app/helpers/blob_helper.rb4
-rw-r--r--app/helpers/button_helper.rb62
-rw-r--r--app/helpers/calendar_helper.rb2
-rw-r--r--app/helpers/ci/jobs_helper.rb14
-rw-r--r--app/helpers/ci/pipeline_schedules_helper.rb19
-rw-r--r--app/helpers/ci/pipelines_helper.rb12
-rw-r--r--app/helpers/clusters_helper.rb6
-rw-r--r--app/helpers/colors_helper.rb4
-rw-r--r--app/helpers/emails_helper.rb2
-rw-r--r--app/helpers/environment_helper.rb7
-rw-r--r--app/helpers/environments_helper.rb26
-rw-r--r--app/helpers/feed_token_helper.rb12
-rw-r--r--app/helpers/form_helper.rb8
-rw-r--r--app/helpers/groups_helper.rb5
-rw-r--r--app/helpers/integrations_helper.rb8
-rw-r--r--app/helpers/issuables_helper.rb6
-rw-r--r--app/helpers/issues_helper.rb4
-rw-r--r--app/helpers/namespaces_helper.rb8
-rw-r--r--app/helpers/nav/new_dropdown_helper.rb8
-rw-r--r--app/helpers/nav/top_nav_helper.rb10
-rw-r--r--app/helpers/packages_helper.rb10
-rw-r--r--app/helpers/projects/observability_helper.rb13
-rw-r--r--app/helpers/projects/pages_helper.rb12
-rw-r--r--app/helpers/projects/pipeline_helper.rb2
-rw-r--r--app/helpers/projects_helper.rb95
-rw-r--r--app/helpers/rss_helper.rb2
-rw-r--r--app/helpers/search_helper.rb89
-rw-r--r--app/helpers/sidebars_helper.rb24
-rw-r--r--app/helpers/sorting_helper.rb11
-rw-r--r--app/helpers/storage_helper.rb6
-rw-r--r--app/helpers/time_helper.rb4
-rw-r--r--app/helpers/timeboxes_helper.rb4
-rw-r--r--app/helpers/users_helper.rb6
-rw-r--r--app/helpers/web_ide_button_helper.rb4
-rw-r--r--app/mailers/emails/issues.rb2
-rw-r--r--app/mailers/emails/notes.rb15
-rw-r--r--app/mailers/emails/profile.rb56
-rw-r--r--app/mailers/previews/notify_preview.rb5
-rw-r--r--app/models/abuse/trust_score.rb15
-rw-r--r--app/models/abuse/user_trust_score.rb53
-rw-r--r--app/models/ai/service_access_token.rb25
-rw-r--r--app/models/alert_management/http_integration.rb21
-rw-r--r--app/models/analytics/cycle_analytics/stage.rb13
-rw-r--r--app/models/analytics/cycle_analytics/value_stream.rb10
-rw-r--r--app/models/application_setting.rb9
-rw-r--r--app/models/audit_event.rb34
-rw-r--r--app/models/award_emoji.rb11
-rw-r--r--app/models/broadcast_message.rb3
-rw-r--r--app/models/bulk_import.rb8
-rw-r--r--app/models/bulk_imports/batch_tracker.rb4
-rw-r--r--app/models/bulk_imports/entity.rb23
-rw-r--r--app/models/bulk_imports/export.rb11
-rw-r--r--app/models/bulk_imports/export_status.rb40
-rw-r--r--app/models/bulk_imports/tracker.rb5
-rw-r--r--app/models/ci/artifact_blob.rb25
-rw-r--r--app/models/ci/bridge.rb107
-rw-r--r--app/models/ci/build_metadata.rb2
-rw-r--r--app/models/ci/build_need.rb6
-rw-r--r--app/models/ci/build_pending_state.rb3
-rw-r--r--app/models/ci/build_report_result.rb3
-rw-r--r--app/models/ci/build_runner_session.rb3
-rw-r--r--app/models/ci/build_trace_chunk.rb7
-rw-r--r--app/models/ci/build_trace_metadata.rb3
-rw-r--r--app/models/ci/catalog/resource.rb2
-rw-r--r--app/models/ci/external_pull_request.rb106
-rw-r--r--app/models/ci/group_variable.rb16
-rw-r--r--app/models/ci/job_artifact.rb3
-rw-r--r--app/models/ci/job_variable.rb3
-rw-r--r--app/models/ci/pending_build.rb3
-rw-r--r--app/models/ci/persistent_ref.rb7
-rw-r--r--app/models/ci/pipeline.rb12
-rw-r--r--app/models/ci/pipeline_variable.rb5
-rw-r--r--app/models/ci/runner.rb4
-rw-r--r--app/models/ci/runner_manager.rb13
-rw-r--r--app/models/ci/running_build.rb3
-rw-r--r--app/models/ci/sources/pipeline.rb3
-rw-r--r--app/models/ci/stage.rb5
-rw-r--r--app/models/ci/unit_test_failure.rb3
-rw-r--r--app/models/ci/variable.rb1
-rw-r--r--app/models/clusters/agent.rb2
-rw-r--r--app/models/clusters/concerns/prometheus_client.rb2
-rw-r--r--app/models/commit.rb4
-rw-r--r--app/models/commit_range.rb2
-rw-r--r--app/models/commit_status.rb4
-rw-r--r--app/models/concerns/commit_signature.rb3
-rw-r--r--app/models/concerns/database_event_tracking.rb15
-rw-r--r--app/models/concerns/enums/ci/pipeline.rb3
-rw-r--r--app/models/concerns/enums/vulnerability.rb8
-rw-r--r--app/models/concerns/expirable.rb6
-rw-r--r--app/models/concerns/has_user_type.rb2
-rw-r--r--app/models/concerns/ignorable_columns.rb2
-rw-r--r--app/models/concerns/issue_available_features.rb2
-rw-r--r--app/models/concerns/issues/forbid_issue_type_column_usage.rb59
-rw-r--r--app/models/concerns/milestoneish.rb5
-rw-r--r--app/models/concerns/packages/debian/component_file.rb2
-rw-r--r--app/models/concerns/project_features_compatibility.rb2
-rw-r--r--app/models/concerns/protected_ref.rb7
-rw-r--r--app/models/concerns/protected_ref_access.rb22
-rw-r--r--app/models/concerns/protected_ref_deploy_key_access.rb61
-rw-r--r--app/models/concerns/spammable.rb6
-rw-r--r--app/models/concerns/triggerable_hooks.rb3
-rw-r--r--app/models/concerns/vulnerability_finding_helpers.rb1
-rw-r--r--app/models/concerns/vulnerability_finding_signature_helpers.rb9
-rw-r--r--app/models/container_repository.rb4
-rw-r--r--app/models/deployment.rb4
-rw-r--r--app/models/design_management/repository.rb2
-rw-r--r--app/models/environment.rb20
-rw-r--r--app/models/external_issue.rb2
-rw-r--r--app/models/external_pull_request.rb106
-rw-r--r--app/models/group.rb100
-rw-r--r--app/models/hooks/project_hook.rb3
-rw-r--r--app/models/hooks/web_hook_log.rb2
-rw-r--r--app/models/integration.rb9
-rw-r--r--app/models/integrations/base_chat_notification.rb4
-rw-r--r--app/models/integrations/base_slack_notification.rb11
-rw-r--r--app/models/integrations/chat_message/group_mention_message.rb102
-rw-r--r--app/models/integrations/hangouts_chat.rb37
-rw-r--r--app/models/integrations/microsoft_teams.rb37
-rw-r--r--app/models/integrations/prometheus.rb6
-rw-r--r--app/models/integrations/unify_circuit.rb34
-rw-r--r--app/models/integrations/webex_teams.rb37
-rw-r--r--app/models/issue.rb103
-rw-r--r--app/models/member.rb11
-rw-r--r--app/models/members/group_member.rb1
-rw-r--r--app/models/merge_request.rb28
-rw-r--r--app/models/merge_request/diff_llm_summary.rb14
-rw-r--r--app/models/merge_request/metrics.rb2
-rw-r--r--app/models/milestone.rb8
-rw-r--r--app/models/ml/experiment.rb12
-rw-r--r--app/models/ml/model.rb25
-rw-r--r--app/models/ml/model_version.rb38
-rw-r--r--app/models/namespace.rb9
-rw-r--r--app/models/namespaces/traversal/linear.rb33
-rw-r--r--app/models/namespaces/traversal/linear_scopes.rb16
-rw-r--r--app/models/note.rb12
-rw-r--r--app/models/organizations/organization.rb8
-rw-r--r--app/models/organizations/organization_setting.rb20
-rw-r--r--app/models/organizations/organization_user.rb8
-rw-r--r--app/models/packages/npm/metadatum.rb7
-rw-r--r--app/models/packages/package.rb9
-rw-r--r--app/models/pages/lookup_path.rb11
-rw-r--r--app/models/personal_access_token.rb11
-rw-r--r--app/models/plan_limits.rb36
-rw-r--r--app/models/project.rb80
-rw-r--r--app/models/project_ci_cd_setting.rb3
-rw-r--r--app/models/project_statistics.rb1
-rw-r--r--app/models/projects/topic.rb2
-rw-r--r--app/models/projects/triggered_hooks.rb2
-rw-r--r--app/models/protected_branch/push_access_level.rb44
-rw-r--r--app/models/protected_tag/create_access_level.rb45
-rw-r--r--app/models/release.rb4
-rw-r--r--app/models/remote_mirror.rb1
-rw-r--r--app/models/repository.rb2
-rw-r--r--app/models/service_desk_setting.rb9
-rw-r--r--app/models/system_access.rb7
-rw-r--r--app/models/todo.rb13
-rw-r--r--app/models/user.rb67
-rw-r--r--app/models/user_custom_attribute.rb2
-rw-r--r--app/models/user_preference.rb4
-rw-r--r--app/models/users/callout.rb19
-rw-r--r--app/models/users/group_callout.rb16
-rw-r--r--app/models/webauthn_registration.rb4
-rw-r--r--app/models/work_item.rb10
-rw-r--r--app/models/work_items/widgets/base.rb4
-rw-r--r--app/models/work_items/widgets/current_user_todos.rb13
-rw-r--r--app/policies/global_policy.rb8
-rw-r--r--app/policies/group_policy.rb8
-rw-r--r--app/policies/merge_request_policy.rb8
-rw-r--r--app/policies/project_policy.rb20
-rw-r--r--app/presenters/alert_management/alert_presenter.rb21
-rw-r--r--app/presenters/blob_presenter.rb21
-rw-r--r--app/presenters/ci/pipeline_presenter.rb38
-rw-r--r--app/presenters/ml/models_index_presenter.rb21
-rw-r--r--app/presenters/project_presenter.rb20
-rw-r--r--app/serializers/diff_viewer_entity.rb2
-rw-r--r--app/serializers/environment_entity.rb8
-rw-r--r--app/serializers/environment_status_entity.rb4
-rw-r--r--app/serializers/lfs_file_lock_entity.rb4
-rw-r--r--app/serializers/prometheus_alert_entity.rb23
-rw-r--r--app/serializers/prometheus_alert_serializer.rb5
-rw-r--r--app/services/admin/plan_limits/update_service.rb14
-rw-r--r--app/services/application_settings/update_service.rb14
-rw-r--r--app/services/auto_merge/merge_when_pipeline_succeeds_service.rb14
-rw-r--r--app/services/award_emojis/add_service.rb2
-rw-r--r--app/services/award_emojis/base_service.rb7
-rw-r--r--app/services/award_emojis/destroy_service.rb1
-rw-r--r--app/services/boards/base_items_list_service.rb19
-rw-r--r--app/services/bulk_imports/create_service.rb4
-rw-r--r--app/services/bulk_imports/relation_export_service.rb11
-rw-r--r--app/services/ci/create_pipeline_schedule_service.rb1
-rw-r--r--app/services/ci/create_pipeline_service.rb30
-rw-r--r--app/services/ci/destroy_pipeline_service.rb2
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service.rb14
-rw-r--r--app/services/ci/pipeline_schedules/create_service.rb47
-rw-r--r--app/services/ci/pipeline_schedules/update_service.rb19
-rw-r--r--app/services/clusters/agent_tokens/create_service.rb2
-rw-r--r--app/services/clusters/agents/authorize_proxy_user_service.rb21
-rw-r--r--app/services/clusters/cleanup/project_namespace_service.rb2
-rw-r--r--app/services/clusters/cleanup/service_account_service.rb2
-rw-r--r--app/services/clusters/integrations/prometheus_health_check_service.rb101
-rw-r--r--app/services/concerns/integrations/project_test_data.rb15
-rw-r--r--app/services/concerns/projects/remove_refs.rb24
-rw-r--r--app/services/draft_notes/create_service.rb3
-rw-r--r--app/services/draft_notes/publish_service.rb7
-rw-r--r--app/services/environments/create_service.rb2
-rw-r--r--app/services/environments/update_service.rb2
-rw-r--r--app/services/git/base_hooks_service.rb19
-rw-r--r--app/services/groups/participants_service.rb12
-rw-r--r--app/services/groups/transfer_service.rb5
-rw-r--r--app/services/groups/update_service.rb1
-rw-r--r--app/services/groups/update_shared_runners_service.rb42
-rw-r--r--app/services/import/github_service.rb11
-rw-r--r--app/services/import_csv/base_service.rb2
-rw-r--r--app/services/import_csv/preprocess_milestones_service.rb35
-rw-r--r--app/services/integrations/group_mention_service.rb59
-rw-r--r--app/services/integrations/test/project_service.rb2
-rw-r--r--app/services/issuable/import_csv/base_service.rb18
-rw-r--r--app/services/issues/base_service.rb19
-rw-r--r--app/services/issues/build_service.rb25
-rw-r--r--app/services/issues/create_service.rb30
-rw-r--r--app/services/issues/export_csv_service.rb10
-rw-r--r--app/services/issues/update_service.rb15
-rw-r--r--app/services/members/creator_service.rb50
-rw-r--r--app/services/members/groups/creator_service.rb2
-rw-r--r--app/services/merge_requests/base_service.rb19
-rw-r--r--app/services/merge_requests/cleanup_refs_service.rb5
-rw-r--r--app/services/merge_requests/merge_to_ref_service.rb10
-rw-r--r--app/services/merge_requests/mergeability_check_batch_service.rb20
-rw-r--r--app/services/merge_requests/refresh_service.rb6
-rw-r--r--app/services/milestones/create_service.rb8
-rw-r--r--app/services/milestones/update_service.rb13
-rw-r--r--app/services/namespace_settings/update_service.rb17
-rw-r--r--app/services/notes/post_process_service.rb11
-rw-r--r--app/services/packages/debian/find_or_create_package_service.rb42
-rw-r--r--app/services/packages/debian/process_changes_service.rb113
-rw-r--r--app/services/packages/npm/create_metadata_cache_service.rb2
-rw-r--r--app/services/packages/npm/create_package_service.rb4
-rw-r--r--app/services/packages/npm/deprecate_package_service.rb2
-rw-r--r--app/services/packages/npm/generate_metadata_service.rb2
-rw-r--r--app/services/packages/nuget/extract_metadata_content_service.rb85
-rw-r--r--app/services/packages/nuget/extract_metadata_file_service.rb62
-rw-r--r--app/services/packages/nuget/metadata_extraction_service.rb115
-rw-r--r--app/services/packages/nuget/update_package_from_metadata_service.rb2
-rw-r--r--app/services/personal_access_tokens/last_used_service.rb7
-rw-r--r--app/services/personal_access_tokens/revoke_token_family_service.rb36
-rw-r--r--app/services/personal_access_tokens/rotate_service.rb1
-rw-r--r--app/services/projects/destroy_service.rb4
-rw-r--r--app/services/projects/download_service.rb4
-rw-r--r--app/services/projects/participants_service.rb2
-rw-r--r--app/services/projects/prometheus/alerts/notify_service.rb4
-rw-r--r--app/services/projects/update_remote_mirror_service.rb2
-rw-r--r--app/services/quick_actions/interpret_service.rb3
-rw-r--r--app/services/search/project_service.rb23
-rw-r--r--app/services/search_service.rb10
-rw-r--r--app/services/service_desk/custom_emails/base_service.rb41
-rw-r--r--app/services/service_desk/custom_emails/create_service.rb85
-rw-r--r--app/services/service_desk/custom_emails/destroy_service.rb26
-rw-r--r--app/services/service_desk_settings/update_service.rb4
-rw-r--r--app/services/service_response.rb4
-rw-r--r--app/services/spam/spam_verdict_service.rb2
-rw-r--r--app/services/system_notes/merge_requests_service.rb2
-rw-r--r--app/services/system_notes/time_tracking_service.rb4
-rw-r--r--app/services/test_hooks/project_service.rb2
-rw-r--r--app/services/todo_service.rb2
-rw-r--r--app/services/users/allow_possible_spam_service.rb18
-rw-r--r--app/services/users/ban_service.rb7
-rw-r--r--app/services/users/banned_user_base_service.rb4
-rw-r--r--app/services/users/disallow_possible_spam_service.rb13
-rw-r--r--app/services/web_hook_service.rb1
-rw-r--r--app/services/work_items/export_csv_service.rb2
-rw-r--r--app/uploaders/file_uploader.rb2
-rw-r--r--app/validators/abstract_path_validator.rb2
-rw-r--r--app/validators/cluster_name_validator.rb2
-rw-r--r--app/validators/cron_validator.rb8
-rw-r--r--app/validators/devise_email_validator.rb2
-rw-r--r--app/validators/json_schemas/default_branch_protection_defaults.json10
-rw-r--r--app/validators/json_schemas/organization_settings.json14
-rw-r--r--app/validators/json_schemas/scan_result_policy_vulnerability_attributes.json14
-rw-r--r--app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json38
-rw-r--r--app/validators/line_code_validator.rb2
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml4
-rw-r--r--app/views/admin/application_settings/_ai_access.html.haml5
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml6
-rw-r--r--app/views/admin/application_settings/_diff_limits.html.haml6
-rw-r--r--app/views/admin/application_settings/_email.html.haml2
-rw-r--r--app/views/admin/application_settings/_error_tracking.html.haml6
-rw-r--r--app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml19
-rw-r--r--app/views/admin/application_settings/_help_page.html.haml2
-rw-r--r--app/views/admin/application_settings/_localization.html.haml2
-rw-r--r--app/views/admin/application_settings/_runner_registrars_form.html.haml4
-rw-r--r--app/views/admin/application_settings/_signin.html.haml2
-rw-r--r--app/views/admin/application_settings/_slack.html.haml49
-rw-r--r--app/views/admin/application_settings/_usage.html.haml8
-rw-r--r--app/views/admin/application_settings/appearances/_form.html.haml13
-rw-r--r--app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml2
-rw-r--r--app/views/admin/application_settings/general.html.haml2
-rw-r--r--app/views/admin/application_settings/network.html.haml3
-rw-r--r--app/views/admin/application_settings/service_usage_data.html.haml2
-rw-r--r--app/views/admin/applications/_delete_form.html.haml4
-rw-r--r--app/views/admin/applications/_form.html.haml2
-rw-r--r--app/views/admin/cohorts/_cohorts_table.html.haml2
-rw-r--r--app/views/admin/groups/show.html.haml2
-rw-r--r--app/views/admin/hooks/_form.html.haml4
-rw-r--r--app/views/admin/hooks/edit.html.haml18
-rw-r--r--app/views/admin/hooks/index.html.haml13
-rw-r--r--app/views/admin/labels/_label.html.haml4
-rw-r--r--app/views/admin/projects/show.html.haml6
-rw-r--r--app/views/admin/runners/edit.html.haml3
-rw-r--r--app/views/admin/sessions/_new_base.html.haml10
-rw-r--r--app/views/admin/sessions/_signin_box.html.haml2
-rw-r--r--app/views/admin/sessions/new.html.haml4
-rw-r--r--app/views/admin/sessions/two_factor.html.haml2
-rw-r--r--app/views/admin/topics/_topic.html.haml4
-rw-r--r--app/views/admin/users/_profile.html.haml2
-rw-r--r--app/views/admin/users/projects.html.haml6
-rw-r--r--app/views/admin/users/show.html.haml13
-rw-r--r--app/views/clusters/clusters/_integrations.html.haml16
-rw-r--r--app/views/clusters/clusters/_integrations_tab.html.haml4
-rw-r--r--app/views/clusters/clusters/show.html.haml1
-rw-r--r--app/views/dashboard/todos/_todo.html.haml4
-rw-r--r--app/views/dashboard/todos/index.html.haml2
-rw-r--r--app/views/devise/sessions/_new_crowd.html.haml2
-rw-r--r--app/views/devise/sessions/_new_ldap.html.haml2
-rw-r--r--app/views/devise/shared/_footer.html.haml19
-rw-r--r--app/views/devise/shared/_signup_box.html.haml30
-rw-r--r--app/views/devise/shared/_signup_omniauth_provider_list.haml4
-rw-r--r--app/views/devise/unlocks/new.html.haml18
-rw-r--r--app/views/discussions/_notes.html.haml24
-rw-r--r--app/views/events/_event_push.atom.haml2
-rw-r--r--app/views/explore/projects/_project.atom.builder14
-rw-r--r--app/views/explore/projects/topic.atom.builder9
-rw-r--r--app/views/explore/projects/topic.html.haml3
-rw-r--r--app/views/groups/group_members/index.html.haml8
-rw-r--r--app/views/groups/milestones/_form.html.haml16
-rw-r--r--app/views/groups/packages/index.html.haml1
-rw-r--r--app/views/groups/settings/_general.html.haml2
-rw-r--r--app/views/groups/settings/access_tokens/index.html.haml49
-rw-r--r--app/views/groups/settings/ci_cd/_form.html.haml2
-rw-r--r--app/views/import/fogbugz/new.html.haml3
-rw-r--r--app/views/invites/show.html.haml2
-rw-r--r--app/views/layouts/_google_tag_manager_head.html.haml1
-rw-r--r--app/views/layouts/_head.html.haml39
-rw-r--r--app/views/layouts/_header_search.html.haml2
-rw-r--r--app/views/layouts/_img_loader.html.haml2
-rw-r--r--app/views/layouts/_mailer.html.haml2
-rw-r--r--app/views/layouts/_page.html.haml4
-rw-r--r--app/views/layouts/_startup_css.haml9
-rw-r--r--app/views/layouts/_startup_css_activation.haml7
-rw-r--r--app/views/layouts/devise.html.haml22
-rw-r--r--app/views/layouts/devise_empty.html.haml16
-rw-r--r--app/views/layouts/errors.html.haml2
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml6
-rw-r--r--app/views/layouts/header/_default.html.haml18
-rw-r--r--app/views/layouts/header/_new_dropdown.html.haml2
-rw-r--r--app/views/layouts/in_product_marketing_mailer.html.haml2
-rw-r--r--app/views/layouts/jira_connect.html.haml2
-rw-r--r--app/views/layouts/nav/_ask_duo_button.html.haml13
-rw-r--r--app/views/layouts/nav/_top_bar.html.haml9
-rw-r--r--app/views/layouts/nav/sidebar/_organization.html.haml1
-rw-r--r--app/views/layouts/notify.html.haml2
-rw-r--r--app/views/layouts/oauth_error.html.haml2
-rw-r--r--app/views/layouts/organization.html.haml6
-rw-r--r--app/views/layouts/service_desk.html.haml2
-rw-r--r--app/views/layouts/signup_onboarding.html.haml21
-rw-r--r--app/views/layouts/simple_registration.html.haml11
-rw-r--r--app/views/notify/_successful_pipeline.html.haml3
-rw-r--r--app/views/notify/_successful_pipeline.text.erb7
-rw-r--r--app/views/notify/approved_merge_request_email.html.haml2
-rw-r--r--app/views/notify/import_issues_csv_email.html.haml16
-rw-r--r--app/views/notify/import_issues_csv_email.text.erb17
-rw-r--r--app/views/notify/issue_due_email.html.haml2
-rw-r--r--app/views/notify/merge_when_pipeline_succeeds_email.html.haml2
-rw-r--r--app/views/notify/pipeline_failed_email.html.haml5
-rw-r--r--app/views/notify/pipeline_failed_email.text.erb18
-rw-r--r--app/views/notify/pipeline_fixed_email.html.haml2
-rw-r--r--app/views/notify/pipeline_fixed_email.text.erb2
-rw-r--r--app/views/notify/pipeline_success_email.html.haml2
-rw-r--r--app/views/notify/pipeline_success_email.text.erb2
-rw-r--r--app/views/notify/prometheus_alert_fired_email.html.haml4
-rw-r--r--app/views/notify/prometheus_alert_fired_email.text.erb4
-rw-r--r--app/views/notify/repository_push_email.html.haml2
-rw-r--r--app/views/notify/repository_push_email.text.haml2
-rw-r--r--app/views/notify/unapproved_merge_request_email.html.haml2
-rw-r--r--app/views/organizations/organizations/directory.html.haml2
-rw-r--r--app/views/organizations/organizations/groups_and_projects.html.haml3
-rw-r--r--app/views/organizations/organizations/show.html.haml2
-rw-r--r--app/views/profiles/accounts/_providers.html.haml4
-rw-r--r--app/views/profiles/accounts/show.html.haml144
-rw-r--r--app/views/profiles/active_sessions/_active_session.html.haml6
-rw-r--r--app/views/profiles/active_sessions/index.html.haml23
-rw-r--r--app/views/profiles/audit_log.html.haml17
-rw-r--r--app/views/profiles/chat_names/_chat_name.html.haml2
-rw-r--r--app/views/profiles/chat_names/index.html.haml44
-rw-r--r--app/views/profiles/comment_templates/index.html.haml19
-rw-r--r--app/views/profiles/emails/index.html.haml32
-rw-r--r--app/views/profiles/gpg_keys/_key.html.haml7
-rw-r--r--app/views/profiles/gpg_keys/index.html.haml40
-rw-r--r--app/views/profiles/keys/_key_details.html.haml6
-rw-r--r--app/views/profiles/keys/index.html.haml48
-rw-r--r--app/views/profiles/notifications/_email_settings.html.haml7
-rw-r--r--app/views/profiles/notifications/_group_settings.html.haml16
-rw-r--r--app/views/profiles/notifications/_project_settings.html.haml13
-rw-r--r--app/views/profiles/notifications/show.html.haml102
-rw-r--r--app/views/profiles/passwords/edit.html.haml60
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml37
-rw-r--r--app/views/profiles/preferences/show.html.haml277
-rw-r--r--app/views/profiles/show.html.haml282
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml5
-rw-r--r--app/views/projects/_activity.html.haml3
-rw-r--r--app/views/projects/_customize_workflow.html.haml2
-rw-r--r--app/views/projects/_export.html.haml14
-rw-r--r--app/views/projects/_find_file_link.html.haml2
-rw-r--r--app/views/projects/_home_panel.html.haml4
-rw-r--r--app/views/projects/_import_project_pane.html.haml4
-rw-r--r--app/views/projects/_new_project_fields.html.haml2
-rw-r--r--app/views/projects/_readme.html.haml2
-rw-r--r--app/views/projects/_service_desk_settings.html.haml4
-rw-r--r--app/views/projects/_wiki.html.haml2
-rw-r--r--app/views/projects/artifacts/browse.html.haml6
-rw-r--r--app/views/projects/artifacts/external_file.html.haml5
-rw-r--r--app/views/projects/blob/_breadcrumb.html.haml13
-rw-r--r--app/views/projects/blob/_new_dir.html.haml2
-rw-r--r--app/views/projects/branches/_branch.html.haml62
-rw-r--r--app/views/projects/branches/_commit.html.haml4
-rw-r--r--app/views/projects/branches/_panel.html.haml4
-rw-r--r--app/views/projects/branches/index.html.haml14
-rw-r--r--app/views/projects/branches/new.html.haml2
-rw-r--r--app/views/projects/buttons/_clone.html.haml2
-rw-r--r--app/views/projects/buttons/_download.html.haml2
-rw-r--r--app/views/projects/buttons/_download_links.html.haml2
-rw-r--r--app/views/projects/buttons/_fork.html.haml12
-rw-r--r--app/views/projects/buttons/_star.html.haml9
-rw-r--r--app/views/projects/ci/builds/_build.html.haml29
-rw-r--r--app/views/projects/commit/_commit_box.html.haml2
-rw-r--r--app/views/projects/commit/_pipelines_list.haml1
-rw-r--r--app/views/projects/commit/_signature_badge_user.html.haml21
-rw-r--r--app/views/projects/commit/_verified_system_signature_badge.html.haml5
-rw-r--r--app/views/projects/commit/x509/_signature_badge_user.html.haml19
-rw-r--r--app/views/projects/commits/_commit.html.haml2
-rw-r--r--app/views/projects/commits/_commit_list.html.haml2
-rw-r--r--app/views/projects/commits/show.html.haml5
-rw-r--r--app/views/projects/compare/index.html.haml14
-rw-r--r--app/views/projects/compare/show.html.haml10
-rw-r--r--app/views/projects/confluences/show.html.haml2
-rw-r--r--app/views/projects/deploy_keys/edit.html.haml2
-rw-r--r--app/views/projects/diffs/_diffs.html.haml2
-rw-r--r--app/views/projects/diffs/_file.html.haml4
-rw-r--r--app/views/projects/edit.html.haml14
-rw-r--r--app/views/projects/environments/edit.html.haml4
-rw-r--r--app/views/projects/environments/empty_metrics.html.haml14
-rw-r--r--app/views/projects/environments/metrics.html.haml6
-rw-r--r--app/views/projects/environments/new.html.haml2
-rw-r--r--app/views/projects/environments/terminal.html.haml3
-rw-r--r--app/views/projects/find_file/show.html.haml8
-rw-r--r--app/views/projects/forks/error.html.haml2
-rw-r--r--app/views/projects/forks/index.html.haml10
-rw-r--r--app/views/projects/hook_logs/show.html.haml2
-rw-r--r--app/views/projects/hooks/edit.html.haml15
-rw-r--r--app/views/projects/hooks/index.html.haml13
-rw-r--r--app/views/projects/integrations/shimos/show.html.haml2
-rw-r--r--app/views/projects/issues/_related_branches.html.haml41
-rw-r--r--app/views/projects/issues/service_desk.html.haml31
-rw-r--r--app/views/projects/issues/service_desk/_issue.html.haml2
-rw-r--r--app/views/projects/issues/service_desk/_nav_btns.html.haml11
-rw-r--r--app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml2
-rw-r--r--app/views/projects/issues/service_desk/_service_desk_info_content.html.haml2
-rw-r--r--app/views/projects/jobs/_table.html.haml2
-rw-r--r--app/views/projects/jobs/show.html.haml2
-rw-r--r--app/views/projects/labels/index.html.haml27
-rw-r--r--app/views/projects/mattermosts/_no_teams.html.haml2
-rw-r--r--app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml2
-rw-r--r--app/views/projects/merge_requests/_form.html.haml1
-rw-r--r--app/views/projects/merge_requests/_mr_box.html.haml2
-rw-r--r--app/views/projects/merge_requests/_source_and_target.html.haml10
-rw-r--r--app/views/projects/merge_requests/_widget.html.haml7
-rw-r--r--app/views/projects/merge_requests/creations/_new_submit.html.haml1
-rw-r--r--app/views/projects/milestones/_form.html.haml21
-rw-r--r--app/views/projects/milestones/index.html.haml6
-rw-r--r--app/views/projects/ml/models/index.html.haml5
-rw-r--r--app/views/projects/no_repo.html.haml10
-rw-r--r--app/views/projects/packages/packages/index.html.haml1
-rw-r--r--app/views/projects/pages/_access.html.haml2
-rw-r--r--app/views/projects/pages/_list.html.haml4
-rw-r--r--app/views/projects/pages/new.html.haml8
-rw-r--r--app/views/projects/pages/show.html.haml4
-rw-r--r--app/views/projects/pages_domains/_dns.html.haml6
-rw-r--r--app/views/projects/pages_domains/_lets_encrypt_callout.html.haml2
-rw-r--r--app/views/projects/pages_domains/new.html.haml2
-rw-r--r--app/views/projects/pages_domains/show.html.haml2
-rw-r--r--app/views/projects/pipeline_schedules/_form.html.haml2
-rw-r--r--app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml9
-rw-r--r--app/views/projects/pipeline_schedules/edit.html.haml3
-rw-r--r--app/views/projects/pipeline_schedules/index.html.haml6
-rw-r--r--app/views/projects/pipeline_schedules/new.html.haml2
-rw-r--r--app/views/projects/pipelines/_info.html.haml75
-rw-r--r--app/views/projects/pipelines/show.html.haml8
-rw-r--r--app/views/projects/project_templates/_template.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_index.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_protected_tag.html.haml2
-rw-r--r--app/views/projects/runners/_group_runners.html.haml4
-rw-r--r--app/views/projects/runners/_project_runners.html.haml26
-rw-r--r--app/views/projects/runners/_runner.html.haml13
-rw-r--r--app/views/projects/settings/_archive.html.haml12
-rw-r--r--app/views/projects/settings/_general.html.haml2
-rw-r--r--app/views/projects/settings/access_tokens/index.html.haml29
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml5
-rw-r--r--app/views/projects/settings/slacks/edit.html.haml2
-rw-r--r--app/views/projects/snippets/index.html.haml2
-rw-r--r--app/views/projects/tags/_edit_release_button.html.haml3
-rw-r--r--app/views/projects/tags/index.html.haml5
-rw-r--r--app/views/projects/tags/show.html.haml6
-rw-r--r--app/views/projects/tracing/index.html.haml4
-rw-r--r--app/views/protected_branches/shared/_protected_branch.html.haml2
-rw-r--r--app/views/registrations/welcome/show.html.haml1
-rw-r--r--app/views/search/results/_issuable.html.haml7
-rw-r--r--app/views/search/results/_wiki_blob.html.haml5
-rw-r--r--app/views/sent_notifications/unsubscribe.html.haml2
-rw-r--r--app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml4
-rw-r--r--app/views/shared/_md_preview.html.haml11
-rw-r--r--app/views/shared/_new_merge_request_checkbox.html.haml3
-rw-r--r--app/views/shared/_no_ssh.html.haml4
-rw-r--r--app/views/shared/_project_limit.html.haml4
-rw-r--r--app/views/shared/_prometheus_configuration_banner.html.haml4
-rw-r--r--app/views/shared/_registration_features_discovery_message.html.haml2
-rw-r--r--app/views/shared/_remote_mirror_update_button.html.haml3
-rw-r--r--app/views/shared/_service_ping_consent.html.haml13
-rw-r--r--app/views/shared/_two_factor_auth_recovery_settings_check.html.haml2
-rw-r--r--app/views/shared/access_tokens/_form.html.haml35
-rw-r--r--app/views/shared/blob/_markdown_buttons.html.haml27
-rw-r--r--app/views/shared/deploy_tokens/_form.html.haml2
-rw-r--r--app/views/shared/deploy_tokens/_table.html.haml2
-rw-r--r--app/views/shared/doorkeeper/applications/_form.html.haml2
-rw-r--r--app/views/shared/doorkeeper/applications/_index.html.haml164
-rw-r--r--app/views/shared/doorkeeper/applications/_show.html.haml4
-rw-r--r--app/views/shared/empty_states/_issues.html.haml8
-rw-r--r--app/views/shared/empty_states/_labels.html.haml6
-rw-r--r--app/views/shared/empty_states/_merge_requests.html.haml6
-rw-r--r--app/views/shared/empty_states/_priority_labels.html.haml6
-rw-r--r--app/views/shared/empty_states/_profile_tabs.html.haml4
-rw-r--r--app/views/shared/empty_states/_snippets.html.haml4
-rw-r--r--app/views/shared/empty_states/_wikis.html.haml4
-rw-r--r--app/views/shared/file_hooks/_index.html.haml50
-rw-r--r--app/views/shared/form_elements/_apply_generated_description_warning.haml13
-rw-r--r--app/views/shared/form_elements/_description.html.haml3
-rw-r--r--app/views/shared/hook_logs/_index.html.haml13
-rw-r--r--app/views/shared/integrations/gitlab_slack_application/_slack_button.html.haml4
-rw-r--r--app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml13
-rw-r--r--app/views/shared/integrations/prometheus/_custom_metrics.html.haml2
-rw-r--r--app/views/shared/integrations/slack_slash_commands/_help.html.haml2
-rw-r--r--app/views/shared/issuable/_form.html.haml6
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml56
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml4
-rw-r--r--app/views/shared/issuable/_sidebar_assignees.html.haml1
-rw-r--r--app/views/shared/issuable/_status_box.html.haml2
-rw-r--r--app/views/shared/issue_type/_details_header.html.haml3
-rw-r--r--app/views/shared/members/_manage_access_button.html.haml6
-rw-r--r--app/views/shared/members/_member.html.haml2
-rw-r--r--app/views/shared/milestones/_labels_tab.html.haml8
-rw-r--r--app/views/shared/milestones/_milestone.html.haml4
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml4
-rw-r--r--app/views/shared/nav/_sidebar.html.haml2
-rw-r--r--app/views/shared/nav/_sidebar_menu.html.haml2
-rw-r--r--app/views/shared/nav/_sidebar_menu_item.html.haml2
-rw-r--r--app/views/shared/notes/_form.html.haml6
-rw-r--r--app/views/shared/notes/_hints.html.haml12
-rw-r--r--app/views/shared/projects/_search_form.html.haml2
-rw-r--r--app/views/shared/web_hooks/_form.html.haml35
-rw-r--r--app/views/shared/web_hooks/_hook.html.haml10
-rw-r--r--app/views/shared/web_hooks/_index.html.haml33
-rw-r--r--app/views/shared/web_hooks/_title_and_docs.html.haml10
-rw-r--r--app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml2
-rw-r--r--app/views/shared/wikis/_main_links.html.haml4
-rw-r--r--app/views/shared/wikis/_sidebar.html.haml2
-rw-r--r--app/views/shared/wikis/diff.html.haml2
-rw-r--r--app/views/shared/wikis/pages.html.haml3
-rw-r--r--app/views/shared/wikis/show.html.haml17
-rw-r--r--app/views/users/_follow_user.html.haml11
-rw-r--r--app/views/users/_overview.html.haml6
-rw-r--r--app/views/users/_profile_basic_info.html.haml7
-rw-r--r--app/views/users/_view_gpg_keys.html.haml5
-rw-r--r--app/views/users/_view_user_in_admin_area.html.haml4
-rw-r--r--app/views/users/calendar_activities.html.haml2
-rw-r--r--app/views/users/show.html.haml56
-rw-r--r--app/workers/all_queues.yml99
-rw-r--r--app/workers/bulk_imports/export_request_worker.rb58
-rw-r--r--app/workers/bulk_imports/finish_batched_pipeline_worker.rb45
-rw-r--r--app/workers/bulk_imports/finish_batched_relation_export_worker.rb2
-rw-r--r--app/workers/bulk_imports/pipeline_batch_worker.rb81
-rw-r--r--app/workers/bulk_imports/pipeline_worker.rb23
-rw-r--r--app/workers/bulk_imports/relation_export_worker.rb4
-rw-r--r--app/workers/ci/pipeline_cleanup_ref_worker.rb35
-rw-r--r--app/workers/clusters/integrations/check_prometheus_health_worker.rb24
-rw-r--r--app/workers/container_registry/cleanup_worker.rb17
-rw-r--r--app/workers/container_registry/record_data_repair_detail_worker.rb6
-rw-r--r--app/workers/integrations/execute_worker.rb2
-rw-r--r--app/workers/integrations/group_mention_worker.rb42
-rw-r--r--app/workers/merge_requests/cleanup_ref_worker.rb35
-rw-r--r--app/workers/merge_requests/mergeability_check_batch_worker.rb15
-rw-r--r--app/workers/metrics/dashboard/prune_old_annotations_worker.rb22
-rw-r--r--app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb22
-rw-r--r--app/workers/metrics/dashboard/sync_dashboards_worker.rb19
-rw-r--r--app/workers/packages/debian/process_changes_worker.rb46
-rw-r--r--app/workers/redis_migration_worker.rb40
-rw-r--r--app/workers/run_pipeline_schedule_worker.rb7
1429 files changed, 20001 insertions, 19673 deletions
diff --git a/app/assets/images/callouts/rich_text_editor_illustration.svg b/app/assets/images/callouts/rich_text_editor_illustration.svg
new file mode 100644
index 00000000000..b07d8871fe6
--- /dev/null
+++ b/app/assets/images/callouts/rich_text_editor_illustration.svg
@@ -0,0 +1,79 @@
+<svg width="280" height="130" viewBox="0 0 280 130" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_191_42179)">
+<circle cx="189.5" cy="-42.5" r="131.5" fill="url(#paint0_radial_191_42179)"/>
+<circle cx="-41.5" cy="-97.5" r="198.5" fill="url(#paint1_radial_191_42179)"/>
+<circle cx="309.5" cy="-7.5" r="121.5" fill="url(#paint2_radial_191_42179)"/>
+<g filter="url(#filter0_b_191_42179)">
+<path d="M0 4C0 1.79086 1.79086 0 4 0H276C278.209 0 280 1.79086 280 4V130H0V4Z" fill="white" fill-opacity="0.01"/>
+</g>
+</g>
+<g transform="translate(64, 16)">
+<path d="M135.455 109.089H47.0349C30.7979 109.089 17.6364 95.8523 17.6364 79.5229V0H106.056C122.293 0 135.455 13.2364 135.455 29.5658V109.091V109.089Z" fill="white"/>
+<path d="M37.0022 29H116C116 46 116 63 116 80C116 84.4183 112.549 88 108.293 88L37 88V29H37.0022Z" fill="white"/>
+<path d="M116 16H37V29H116V16Z" fill="#AEA5D6"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M36 15H101V16H37V42H36V15Z" fill="#171321"/>
+<path d="M53 22.5C53 23.8807 51.8807 25 50.5 25C49.1193 25 48 23.8807 48 22.5C48 21.1193 49.1193 20 50.5 20C51.8807 20 53 21.1193 53 22.5Z" fill="#A888F4"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M50.5 24C51.3284 24 52 23.3284 52 22.5C52 21.6716 51.3284 21 50.5 21C49.6716 21 49 21.6716 49 22.5C49 23.3284 49.6716 24 50.5 24ZM50.5 25C51.8807 25 53 23.8807 53 22.5C53 21.1193 51.8807 20 50.5 20C49.1193 20 48 21.1193 48 22.5C48 23.8807 49.1193 25 50.5 25Z" fill="#171321"/>
+<path d="M60 22.5C60 23.8807 58.8807 25 57.5 25C56.1193 25 55 23.8807 55 22.5C55 21.1193 56.1193 20 57.5 20C58.8807 20 60 21.1193 60 22.5Z" fill="#FF9D73"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M57.5 24C58.3284 24 59 23.3284 59 22.5C59 21.6716 58.3284 21 57.5 21C56.6716 21 56 21.6716 56 22.5C56 23.3284 56.6716 24 57.5 24ZM57.5 25C58.8807 25 60 23.8807 60 22.5C60 21.1193 58.8807 20 57.5 20C56.1193 20 55 21.1193 55 22.5C55 23.8807 56.1193 25 57.5 25Z" fill="#171321"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M22.5 10.5C30.3325 10.5 38.152 14.4668 42.923 22.3723L43 22.5L42.923 22.6277C38.152 30.5332 30.3325 34.5 22.5 34.5C14.6675 34.5 6.84799 30.5332 2.07704 22.6277L2 22.5L2.07704 22.3723C6.84799 14.4668 14.6675 10.5 22.5 10.5Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M41.8274 22.5C37.2212 15.1579 29.8576 11.5 22.5 11.5C15.1424 11.5 7.77878 15.1579 3.1726 22.5C7.77878 29.8421 15.1424 33.5 22.5 33.5C29.8576 33.5 37.2212 29.8421 41.8274 22.5ZM2 22.5L2.07704 22.6277C6.84799 30.5332 14.6675 34.5 22.5 34.5C30.3325 34.5 38.152 30.5332 42.923 22.6277L43 22.5L42.923 22.3723C38.152 14.4668 30.3325 10.5 22.5 10.5C14.6675 10.5 6.84799 14.4668 2.07704 22.3723L2 22.5Z" fill="#171321"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M0 22.5C0 21.3954 0.895434 20.5 2 20.5C3.10457 20.5 4 21.3954 4 22.5C4 23.6046 3.10457 24.5 2 24.5C0.895434 24.5 0 23.6046 0 22.5Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M2 21.5C1.44772 21.5 1 21.9477 1 22.5C1 23.0523 1.44772 23.5 2 23.5C2.55229 23.5 3 23.0523 3 22.5C3 21.9477 2.55229 21.5 2 21.5ZM2 20.5C0.895434 20.5 0 21.3954 0 22.5C0 23.6046 0.895434 24.5 2 24.5C3.10457 24.5 4 23.6046 4 22.5C4 21.3954 3.10457 20.5 2 20.5Z" fill="#171321"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M41 22.5C41 21.3954 41.8954 20.5 43 20.5C44.1046 20.5 45 21.3954 45 22.5C45 23.6046 44.1046 24.5 43 24.5C41.8954 24.5 41 23.6046 41 22.5Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M43 21.5C42.4477 21.5 42 21.9477 42 22.5C42 23.0523 42.4477 23.5 43 23.5C43.5523 23.5 44 23.0523 44 22.5C44 21.9477 43.5523 21.5 43 21.5ZM43 20.5C41.8954 20.5 41 21.3954 41 22.5C41 23.6046 41.8954 24.5 43 24.5C44.1046 24.5 45 23.6046 45 22.5C45 21.3954 44.1046 20.5 43 20.5Z" fill="#171321"/>
+<path d="M22.5 30C26.6421 30 30 26.6421 30 22.5C30 18.3579 26.6421 15 22.5 15C18.3579 15 15 18.3579 15 22.5C15 26.6421 18.3579 30 22.5 30Z" fill="#10B1B1"/>
+<path d="M27.0838 22.3192C27.0838 23.5746 25.3096 22.4248 23.8629 20.9715C22.4317 19.5337 21.3192 17.7969 22.5614 17.7969C25.0589 17.7969 27.0838 19.8217 27.0838 22.3192Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37 34V65H36V34H37Z" fill="#171321"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37 93V70.0117H36V94H57V93H37Z" fill="#171321"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M64 93H93V94H64V93Z" fill="#171321"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M116 65V38H117V65H116Z" fill="#171321"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M144 104H122V103H144V104Z" fill="#AEA5D6"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M138 97H129V96H138V97Z" fill="#AEA5D6"/>
+<path d="M104 34H47V46H104V34Z" fill="#E7E4F2"/>
+<path d="M74 51H48V83H74V51Z" fill="#FF9D73"/>
+<path d="M60.5 70C61.8807 70 63 68.8807 63 67.5C63 66.1193 61.8807 65 60.5 65C59.1193 65 58 66.1193 58 67.5C58 68.8807 59.1193 70 60.5 70Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M60.5 69C61.3284 69 62 68.3284 62 67.5C62 66.6716 61.3284 66 60.5 66C59.6716 66 59 66.6716 59 67.5C59 68.3284 59.6716 69 60.5 69ZM63 67.5C63 68.8807 61.8807 70 60.5 70C59.1193 70 58 68.8807 58 67.5C58 66.1193 59.1193 65 60.5 65C61.8807 65 63 66.1193 63 67.5Z" fill="#171321"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74 50V84H73V50H74Z" fill="#171321"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M47 84V70H48V84H47Z" fill="#171321"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M47 65V50H48V65H47Z" fill="#171321"/>
+<path d="M104 51H78V71H104V51Z" fill="#E7E4F2"/>
+<path d="M104 76H78V83H104V76Z" fill="#E7E4F2"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M23 34V58.5C23 63.1944 26.8056 67 31.5 67H59V68H31.5C26.2533 68 22 63.7467 22 58.5V34H23Z" fill="#171321"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M46 51H75V52H46V51Z" fill="#171321"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M75 83H46V82H75V83Z" fill="#171321"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M14 67H32V68H14V67Z" fill="#171321"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M2 67H10V68H2V67Z" fill="#AEA5D6"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M58.5 69.5V95.5C58.5 100.194 62.3056 104 67 104H94L93.5 105H67C61.7533 105 57.5 100.747 57.5 95.5V69.5H58.5Z" fill="#171321"/>
+<rect x="130.598" y="54.4473" width="19" height="46" transform="rotate(45 130.598 54.4473)" fill="white"/>
+<path d="M111.506 100.41L98.0714 86.9746L93.4752 105.006L111.506 100.41Z" fill="#FF9D73"/>
+<path d="M140.498 44.5479L144.033 48.0834C146.666 50.7156 147.982 52.0318 148.701 53.443C150.154 56.2951 150.154 59.6706 148.701 62.5228C147.982 63.934 146.666 65.2501 144.033 67.8824L144.033 67.8824L130.598 54.4473L140.498 44.5479Z" fill="#5829CB"/>
+<path d="M130.598 54.4473L131.305 55.1544L98.7785 87.6813L98.0714 86.9742L130.598 54.4473Z" fill="#171321"/>
+<path d="M143.326 67.1758L144.033 67.8829L111.506 100.41L110.799 99.7027L143.326 67.1758Z" fill="#171321"/>
+<path d="M136.962 60.8115L137.669 61.5186L105.142 94.0455L104.435 93.3384L136.962 60.8115Z" fill="#171321"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M95.4861 97.1172L93.4752 105.006L101.364 102.995L95.4861 97.1172Z" fill="#171321"/>
+</g>
+<defs>
+<filter id="filter0_b_191_42179" x="-50" y="-50" width="380" height="230" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
+<feGaussianBlur in="BackgroundImageFix" stdDeviation="25"/>
+<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_191_42179"/>
+<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_191_42179" result="shape"/>
+</filter>
+<radialGradient id="paint0_radial_191_42179" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(189.5 -42.5) rotate(89.5818) scale(125.986)">
+<stop stop-color="#7759C2"/>
+<stop offset="1" stop-color="#7759C2" stop-opacity="0"/>
+</radialGradient>
+<radialGradient id="paint1_radial_191_42179" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(-41.5 -97.5) rotate(89.5818) scale(190.176)">
+<stop stop-color="#D64028"/>
+<stop offset="1" stop-color="#D64028" stop-opacity="0"/>
+</radialGradient>
+<radialGradient id="paint2_radial_191_42179" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(309.5 -7.5) rotate(89.5818) scale(116.405)">
+<stop stop-color="#EF76F1"/>
+<stop offset="1" stop-color="#EF76F1" stop-opacity="0"/>
+</radialGradient>
+<clipPath id="clip0_191_42179">
+<path d="M0 4C0 1.79086 1.79086 0 4 0H276C278.209 0 280 1.79086 280 4V130H0V4Z" fill="white"/>
+</clipPath>
+</defs>
+</svg>
diff --git a/app/assets/images/service_desk_callout.svg b/app/assets/images/service_desk_callout.svg
new file mode 100644
index 00000000000..2886388279e
--- /dev/null
+++ b/app/assets/images/service_desk_callout.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76 19.575 76 3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><rect width="7" height="1" x="59" y="38" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M60.5 42a3.5 3.5 0 0 0 0-7v7z"/><rect width="7" height="1" x="12" y="38" fill="#E1DBF2" transform="matrix(-1 0 0 1 31 0)" rx=".5"/><path fill="#6B4FBB" d="M17.5 42a3.5 3.5 0 0 1 0-7v7z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M39 58c10.493 0 19-8.507 19-19s-8.507-19-19-19-19 8.507-19 19 8.507 19 19 19zm0 4c-12.703 0-23-10.297-23-23s10.297-23 23-23 23 10.297 23 23-10.297 23-23 23z"/><path fill="#6B4FBB" d="M35 56a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M26.5 40c0 4.143 3.355 7.5 7.494 7.5h10.012A7.497 7.497 0 0 0 51.5 40c0-4.143-3.355-7.5-7.494-7.5H33.994A7.497 7.497 0 0 0 26.5 40zm-3 0c0-5.799 4.698-10.5 10.494-10.5h10.012C49.802 29.5 54.5 34.2 54.5 40c0 5.799-4.698 10.5-10.494 10.5H33.994C28.198 50.5 23.5 45.8 23.5 40z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M35.255 42.406a1 1 0 1 1 1.872-.703 2.001 2.001 0 0 0 3.76-.038 1 1 0 1 1 1.886.665 4 4 0 0 1-7.518.076zM31.5 40a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm15 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/><path fill="#6B4FBB" d="M38 22h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2zm0 3h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2z" style="mix-blend-mode:multiply"/></g></svg> \ No newline at end of file
diff --git a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
index 57a237c3e84..d15c8e6e703 100644
--- a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
+++ b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
@@ -114,8 +114,7 @@ export default {
<template>
<dom-element-listener :selector="$options.FORM_SELECTOR" @[$options.EVENT_SUCCESS]="onSuccess">
- <div>
- <hr />
+ <div class="gl-pt-6">
<h5>{{ header }}</h5>
<p v-if="information" data-testid="information-section">
diff --git a/app/assets/javascripts/access_tokens/components/token.vue b/app/assets/javascripts/access_tokens/components/token.vue
index 3954e541fe0..23803e82476 100644
--- a/app/assets/javascripts/access_tokens/components/token.vue
+++ b/app/assets/javascripts/access_tokens/components/token.vue
@@ -30,26 +30,19 @@ export default {
</script>
<template>
- <div class="row">
- <div class="col-lg-12">
- <hr />
- </div>
- <div class="col-lg-4">
- <h4 class="gl-mt-0"><slot name="title"></slot></h4>
- <slot name="description"></slot>
- </div>
- <div class="col-lg-8">
- <input-copy-toggle-visibility
- :label="inputLabel"
- :label-for="inputId"
- :form-input-group-props="formInputGroupProps"
- :value="token"
- :copy-button-title="copyButtonTitle"
- >
- <template #description>
- <slot name="input-description"></slot>
- </template>
- </input-copy-toggle-visibility>
- </div>
+ <div>
+ <h4 class="gl-my-0"><slot name="title"></slot></h4>
+ <slot name="description"></slot>
+ <input-copy-toggle-visibility
+ :label="inputLabel"
+ :label-for="inputId"
+ :form-input-group-props="formInputGroupProps"
+ :value="token"
+ :copy-button-title="copyButtonTitle"
+ >
+ <template #description>
+ <slot name="input-description"></slot>
+ </template>
+ </input-copy-toggle-visibility>
</div>
</template>
diff --git a/app/assets/javascripts/access_tokens/components/tokens_app.vue b/app/assets/javascripts/access_tokens/components/tokens_app.vue
index 1f72f5e19e2..88119ed8a84 100644
--- a/app/assets/javascripts/access_tokens/components/tokens_app.vue
+++ b/app/assets/javascripts/access_tokens/components/tokens_app.vue
@@ -79,7 +79,7 @@ export default {
</script>
<template>
- <div class="js-search-settings-section">
+ <div class="settings-section gl-pt-0! js-search-settings-section">
<token
v-for="(tokenData, tokenType) in enabledTokenTypes"
:key="tokenType"
@@ -89,10 +89,18 @@ export default {
:copy-button-title="$options.i18n[tokenType].copyButtonTitle"
:data-testid="$options.htmlAttributes[tokenType].containerTestId"
>
- <template #title>{{ $options.i18n[tokenType].label }}</template>
+ <template #title>
+ <div class="settings-sticky-header">
+ <div class="settings-sticky-header-inner">
+ {{ $options.i18n[tokenType].label }}
+ </div>
+ </div>
+ </template>
<template #description>
- <p>{{ $options.i18n[tokenType].description }}</p>
- <p>{{ $options.i18n.canNotAccessOtherData }}</p>
+ <p class="gl-text-secondary">
+ {{ $options.i18n[tokenType].description }}
+ {{ $options.i18n.canNotAccessOtherData }}
+ </p>
</template>
<template #input-description>
<gl-sprintf :message="$options.i18n[tokenType].inputDescription">
diff --git a/app/assets/javascripts/actioncable_connection_monitor.js b/app/assets/javascripts/actioncable_connection_monitor.js
deleted file mode 100644
index fc4e436c7fb..00000000000
--- a/app/assets/javascripts/actioncable_connection_monitor.js
+++ /dev/null
@@ -1,142 +0,0 @@
-/* eslint-disable no-restricted-globals */
-
-import { logger } from '@rails/actioncable';
-
-// This is based on https://github.com/rails/rails/blob/5a477890c809d4a17dc0dede43c6b8cef81d8175/actioncable/app/javascript/action_cable/connection_monitor.js
-// so that we can take advantage of the improved reconnection logic. We can remove this once we upgrade @rails/actioncable to a version that includes this.
-
-// Responsible for ensuring the cable connection is in good health by validating the heartbeat pings sent from the server, and attempting
-// revival reconnections if things go astray. Internal class, not intended for direct user manipulation.
-
-const now = () => new Date().getTime();
-
-const secondsSince = (time) => (now() - time) / 1000;
-class ConnectionMonitor {
- constructor(connection) {
- this.visibilityDidChange = this.visibilityDidChange.bind(this);
- this.connection = connection;
- this.reconnectAttempts = 0;
- }
-
- start() {
- if (!this.isRunning()) {
- this.startedAt = now();
- delete this.stoppedAt;
- this.startPolling();
- addEventListener('visibilitychange', this.visibilityDidChange);
- logger.log(
- `ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`,
- );
- }
- }
-
- stop() {
- if (this.isRunning()) {
- this.stoppedAt = now();
- this.stopPolling();
- removeEventListener('visibilitychange', this.visibilityDidChange);
- logger.log('ConnectionMonitor stopped');
- }
- }
-
- isRunning() {
- return this.startedAt && !this.stoppedAt;
- }
-
- recordPing() {
- this.pingedAt = now();
- }
-
- recordConnect() {
- this.reconnectAttempts = 0;
- this.recordPing();
- delete this.disconnectedAt;
- logger.log('ConnectionMonitor recorded connect');
- }
-
- recordDisconnect() {
- this.disconnectedAt = now();
- logger.log('ConnectionMonitor recorded disconnect');
- }
-
- // Private
-
- startPolling() {
- this.stopPolling();
- this.poll();
- }
-
- stopPolling() {
- clearTimeout(this.pollTimeout);
- }
-
- poll() {
- this.pollTimeout = setTimeout(() => {
- this.reconnectIfStale();
- this.poll();
- }, this.getPollInterval());
- }
-
- getPollInterval() {
- const { staleThreshold, reconnectionBackoffRate } = this.constructor;
- const backoff = (1 + reconnectionBackoffRate) ** Math.min(this.reconnectAttempts, 10);
- const jitterMax = this.reconnectAttempts === 0 ? 1.0 : reconnectionBackoffRate;
- const jitter = jitterMax * Math.random();
- return staleThreshold * 1000 * backoff * (1 + jitter);
- }
-
- reconnectIfStale() {
- if (this.connectionIsStale()) {
- logger.log(
- `ConnectionMonitor detected stale connection. reconnectAttempts = ${
- this.reconnectAttempts
- }, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${
- this.constructor.staleThreshold
- } s`,
- );
- this.reconnectAttempts += 1;
- if (this.disconnectedRecently()) {
- logger.log(
- `ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(
- this.disconnectedAt,
- )} s`,
- );
- } else {
- logger.log('ConnectionMonitor reopening');
- this.connection.reopen();
- }
- }
- }
-
- get refreshedAt() {
- return this.pingedAt ? this.pingedAt : this.startedAt;
- }
-
- connectionIsStale() {
- return secondsSince(this.refreshedAt) > this.constructor.staleThreshold;
- }
-
- disconnectedRecently() {
- return (
- this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold
- );
- }
-
- visibilityDidChange() {
- if (document.visibilityState === 'visible') {
- setTimeout(() => {
- if (this.connectionIsStale() || !this.connection.isOpen()) {
- logger.log(
- `ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`,
- );
- this.connection.reopen();
- }
- }, 200);
- }
- }
-}
-
-ConnectionMonitor.staleThreshold = 6; // Server::Connections::BEAT_INTERVAL * 2 (missed two pings)
-ConnectionMonitor.reconnectionBackoffRate = 0.15;
-
-export default ConnectionMonitor;
diff --git a/app/assets/javascripts/actioncable_consumer.js b/app/assets/javascripts/actioncable_consumer.js
index aeb61e61a3d..5658ffc1a38 100644
--- a/app/assets/javascripts/actioncable_consumer.js
+++ b/app/assets/javascripts/actioncable_consumer.js
@@ -1,10 +1,3 @@
import { createConsumer } from '@rails/actioncable';
-import ConnectionMonitor from './actioncable_connection_monitor';
-const consumer = createConsumer();
-
-if (consumer.connection) {
- consumer.connection.monitor = new ConnectionMonitor(consumer.connection);
-}
-
-export default consumer;
+export default createConsumer();
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_category.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_category.vue
new file mode 100644
index 00000000000..f05f96d6302
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_category.vue
@@ -0,0 +1,33 @@
+<script>
+import { GlLabel } from '@gitlab/ui';
+import { ABUSE_CATEGORIES } from '../constants';
+
+export default {
+ name: 'AbuseCategory',
+ components: {
+ GlLabel,
+ },
+ props: {
+ category: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ categoryObject() {
+ return ABUSE_CATEGORIES[this.category];
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-label
+ v-if="categoryObject"
+ size="sm"
+ :background-color="categoryObject.backgroundColor"
+ :title="categoryObject.title"
+ :target="null"
+ :class="`gl-text-${categoryObject.color}`"
+ />
+</template>
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
index b8a4640de59..b229dd9e993 100644
--- a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
@@ -5,12 +5,14 @@ import { queryToObject } from '~/lib/utils/url_utility';
import { s__, __, sprintf } from '~/locale';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import { SORT_UPDATED_AT } from '../constants';
+import AbuseCategory from './abuse_category.vue';
export default {
name: 'AbuseReportRow',
components: {
GlLink,
ListItem,
+ AbuseCategory,
},
props: {
report: {
@@ -44,13 +46,24 @@ export default {
<template>
<list-item data-testid="abuse-report-row">
<template #left-primary>
- <gl-link :href="report.reportPath" class="gl-font-weight-normal gl-mb-2" data-testid="title">
+ <gl-link
+ :href="report.reportPath"
+ class="gl-font-weight-normal gl-pt-4 gl-text-gray-900"
+ data-testid="abuse-report-title"
+ >
{{ title }}
</gl-link>
</template>
+ <template #left-secondary>
+ <abuse-category
+ :category="report.category"
+ class="gl-mt-2 gl-mb-3"
+ data-testid="abuse-report-category"
+ />
+ </template>
<template #right-secondary>
- <div data-testid="abuse-report-date">{{ displayDate }}</div>
+ <div class="gl-mt-7" data-testid="abuse-report-date">{{ displayDate }}</div>
</template>
</list-item>
</template>
diff --git a/app/assets/javascripts/admin/abuse_reports/constants.js b/app/assets/javascripts/admin/abuse_reports/constants.js
index 9458aea299e..acb79293dfb 100644
--- a/app/assets/javascripts/admin/abuse_reports/constants.js
+++ b/app/assets/javascripts/admin/abuse_reports/constants.js
@@ -5,7 +5,7 @@ import {
OPERATORS_IS,
TOKEN_TITLE_STATUS,
} from '~/vue_shared/components/filtered_search_bar/constants';
-import { __ } from '~/locale';
+import { s__, __ } from '~/locale';
const STATUS_OPTIONS = [
{ value: 'closed', title: __('Closed') },
@@ -78,3 +78,46 @@ export const FILTERED_SEARCH_TOKENS = [
FILTERED_SEARCH_TOKEN_REPORTER,
FILTERED_SEARCH_TOKEN_STATUS,
];
+
+export const ABUSE_CATEGORIES = {
+ spam: {
+ backgroundColor: '#f5d9a8',
+ color: 'orange-700',
+ title: s__('AbuseReport|Spam'),
+ },
+ offensive: {
+ backgroundColor: '#e1d8f9',
+ color: 'purple-700',
+ title: s__('AbuseReport|Offensive or Abusive'),
+ },
+ phishing: {
+ backgroundColor: '#7c7ccc',
+ color: 'indigo-800',
+ title: s__('AbuseReport|Phishing'),
+ },
+ crypto: {
+ backgroundColor: '#fdd4cd',
+ color: 'red-700',
+ title: s__('AbuseReport|Crypto Mining'),
+ },
+ credentials: {
+ backgroundColor: '#cbe2f9',
+ color: 'blue-700',
+ title: s__('AbuseReport|Personal information or credentials'),
+ },
+ copyright: {
+ backgroundColor: '#c3e6cd',
+ color: 'green-700',
+ title: s__('AbuseReport|Copyright or trademark violation'),
+ },
+ malware: {
+ backgroundColor: '#fdd4cd',
+ color: 'red-700',
+ title: s__('AbuseReport|Malware'),
+ },
+ other: {
+ backgroundColor: '#dcdcde',
+ color: 'gray-700',
+ title: s__('AbuseReport|Other'),
+ },
+};
diff --git a/app/assets/javascripts/admin/applications/components/delete_application.vue b/app/assets/javascripts/admin/applications/components/delete_application.vue
index 77694296b0a..287a5537cf4 100644
--- a/app/assets/javascripts/admin/applications/components/delete_application.vue
+++ b/app/assets/javascripts/admin/applications/components/delete_application.vue
@@ -26,7 +26,7 @@ export default {
methods: {
buttonEvent(e) {
e.preventDefault();
- this.show(e.target.dataset);
+ this.show(e.currentTarget.dataset);
},
show(dataset) {
const { name, path } = dataset;
diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
index 427e6c14327..42a959e1b89 100644
--- a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
+++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
@@ -17,7 +17,15 @@ import { createAlert, VARIANT_DANGER } from '~/alert';
import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { THEMES, TYPES, TYPE_BANNER } from '../constants';
+import {
+ THEMES,
+ TYPES,
+ TYPE_BANNER,
+ TARGET_OPTIONS,
+ TARGET_ALL,
+ TARGET_ALL_MATCHING_PATH,
+ TARGET_ROLES,
+} from '../constants';
import DatetimePicker from './datetime_picker.vue';
const FORM_HEADERS = { headers: { 'Content-Type': 'application/json; charset=utf-8' } };
@@ -59,10 +67,8 @@ export default {
theme: s__('BroadcastMessages|Theme'),
dismissable: s__('BroadcastMessages|Dismissable'),
dismissableDescription: s__('BroadcastMessages|Allow users to dismiss the broadcast message'),
+ target: s__('BroadcastMessages|Target broadcast message'),
targetRoles: s__('BroadcastMessages|Target roles'),
- targetRolesDescription: s__(
- 'BroadcastMessages|The broadcast message displays only to users in projects and groups who have these roles.',
- ),
targetPath: s__('BroadcastMessages|Target Path'),
targetPathDescription: s__('BroadcastMessages|Paths can contain wildcards, like */welcome'),
startsAt: s__('BroadcastMessages|Starts at'),
@@ -79,6 +85,7 @@ export default {
},
messageThemes: THEMES,
messageTypes: TYPES,
+ targetOptions: TARGET_OPTIONS,
props: {
broadcastMessage: {
type: Object,
@@ -92,6 +99,7 @@ export default {
type: this.broadcastMessage.broadcastType,
theme: this.broadcastMessage.theme,
dismissable: this.broadcastMessage.dismissable || false,
+ targetSelected: '',
targetPath: this.broadcastMessage.targetPath,
targetAccessLevels: this.broadcastMessage.targetAccessLevels,
targetAccessLevelOptions: this.targetAccessLevelOptions.map(([text, value]) => ({
@@ -122,6 +130,14 @@ export default {
? this.messagesPath
: `${this.messagesPath}/${this.broadcastMessage.id}`;
},
+ showTargetRoles() {
+ return this.targetSelected === TARGET_ROLES;
+ },
+ showTargetPath() {
+ return (
+ this.targetSelected === TARGET_ROLES || this.targetSelected === TARGET_ALL_MATCHING_PATH
+ );
+ },
formPayload() {
return JSON.stringify({
message: this.message,
@@ -143,6 +159,17 @@ export default {
},
immediate: true,
},
+ targetSelected(newTarget) {
+ if (newTarget === TARGET_ALL) {
+ this.targetPath = '';
+ this.targetAccessLevels = [];
+ } else if (newTarget === TARGET_ALL_MATCHING_PATH) {
+ this.targetAccessLevels = [];
+ }
+ },
+ },
+ created() {
+ this.targetSelected = this.initialTarget();
},
methods: {
async onSubmit() {
@@ -179,6 +206,15 @@ export default {
this.renderedMessage = '';
}
},
+
+ initialTarget() {
+ if (this.targetAccessLevels.length > 0) {
+ return TARGET_ROLES;
+ } else if (this.targetPath !== '') {
+ return TARGET_ALL_MATCHING_PATH;
+ }
+ return TARGET_ALL;
+ },
},
safeHtmlConfig: {
ADD_TAGS: ['use'],
@@ -245,14 +281,29 @@ export default {
</gl-form-group>
</template>
- <gl-form-group :label="$options.i18n.targetRoles" data-testid="target-roles-checkboxes">
+ <gl-form-group :label="$options.i18n.target" label-for="target-select">
+ <gl-form-select
+ id="target-select"
+ v-model="targetSelected"
+ :options="$options.targetOptions"
+ data-testid="target-select"
+ />
+ </gl-form-group>
+
+ <gl-form-group
+ v-show="showTargetRoles"
+ :label="$options.i18n.targetRoles"
+ data-testid="target-roles-checkboxes"
+ >
<gl-form-checkbox-group v-model="targetAccessLevels" :options="targetAccessLevelOptions" />
- <gl-form-text>
- {{ $options.i18n.targetRolesDescription }}
- </gl-form-text>
</gl-form-group>
- <gl-form-group :label="$options.i18n.targetPath" label-for="target-path-input">
+ <gl-form-group
+ v-show="showTargetPath"
+ :label="$options.i18n.targetPath"
+ label-for="target-path-input"
+ data-testid="target-path-input"
+ >
<gl-form-input id="target-path-input" v-model="targetPath" />
<gl-form-text>
{{ $options.i18n.targetPathDescription }}
diff --git a/app/assets/javascripts/admin/broadcast_messages/constants.js b/app/assets/javascripts/admin/broadcast_messages/constants.js
index ed137181a48..76e1cf91c2f 100644
--- a/app/assets/javascripts/admin/broadcast_messages/constants.js
+++ b/app/assets/javascripts/admin/broadcast_messages/constants.js
@@ -21,6 +21,27 @@ export const THEMES = [
{ value: 'light', text: s__('BroadcastMessages|Light') },
];
+export const TARGET_ALL = 'target_all';
+export const TARGET_ALL_MATCHING_PATH = 'target_all_matching_path';
+export const TARGET_ROLES = 'target_roles';
+
+export const TARGET_OPTIONS = [
+ {
+ value: TARGET_ALL,
+ text: s__('BroadcastMessages|Show to all users on all pages'),
+ },
+ {
+ value: TARGET_ALL_MATCHING_PATH,
+ text: s__('BroadcastMessages|Show to all users on specific matching pages'),
+ },
+ {
+ value: TARGET_ROLES,
+ text: s__(
+ 'BroadcastMessages|Show only to users who have specific roles on groups/project pages',
+ ),
+ },
+];
+
export const NEW_BROADCAST_MESSAGE = {
message: '',
broadcastType: TYPES[0].value,
diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue
index d7bdceb4798..36dcde619cf 100644
--- a/app/assets/javascripts/admin/users/components/actions/ban.vue
+++ b/app/assets/javascripts/admin/users/components/actions/ban.vue
@@ -12,7 +12,7 @@ const messageHtml = `
<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 and merge requests authored by this user are hidden from other users.',
+ 'AdminUsers|Projects, issues, merge requests, and comments of 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>
diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue
index 2d2c598f953..65737be1e67 100644
--- a/app/assets/javascripts/admin/users/components/users_table.vue
+++ b/app/assets/javascripts/admin/users/components/users_table.vue
@@ -109,7 +109,7 @@ export default {
:empty-text="s__('AdminUsers|No users found')"
show-empty
stacked="md"
- :tbody-tr-attr="{ 'data-qa-selector': 'user_row_content' }"
+ :tbody-tr-attr="{ 'data-testid': 'user-row-content' }"
>
<template #cell(name)="{ item: user }">
<user-avatar :user="user" :admin-user-path="paths.adminUser" />
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 428291f2313..033f48827f1 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
@@ -16,7 +16,7 @@ import {
import * as Sentry from '@sentry/browser';
import { isEqual, isEmpty, omit } from 'lodash';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
-import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
+import { PROMO_URL, DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility';
import {
integrationTypes,
integrationSteps,
@@ -38,6 +38,7 @@ export default {
placeholders: {
prometheus: targetPrometheusUrlPlaceholder,
},
+ incidentManagementDocsLink: `${DOCS_URL_IN_EE_DIR}/operations/incident_management/integrations.html#configuration`,
JSON_VALIDATE_DELAY,
typeSet,
integrationSteps,
@@ -121,14 +122,12 @@ export default {
name: '',
token: '',
url: '',
- apiUrl: '',
},
activeTabIndex: this.tabIndex,
currentIntegration: null,
parsedPayload: [],
validationState: {
name: true,
- apiUrl: true,
},
pricingLink: `${PROMO_URL}/pricing`,
};
@@ -187,20 +186,14 @@ export default {
);
},
isFormDirty() {
- const { type, active, name, apiUrl, payloadAlertFields = [], payloadAttributeMappings = [] } =
+ const { type, active, name, payloadAlertFields = [], payloadAttributeMappings = [] } =
this.currentIntegration || {};
- const {
- name: formName,
- apiUrl: formApiUrl,
- active: formActive,
- type: formType,
- } = this.integrationForm;
+ const { name: formName, active: formActive, type: formType } = this.integrationForm;
const isDirty =
type !== formType ||
active !== formActive ||
name !== formName ||
- apiUrl !== formApiUrl ||
!isEqual(this.parsedPayload, payloadAlertFields) ||
!isEqual(this.mapping, this.getCleanMapping(payloadAttributeMappings));
@@ -210,25 +203,19 @@ export default {
return this.isFormValid && this.isFormDirty;
},
dataForSave() {
- const { name, apiUrl, active } = this.integrationForm;
+ const { name, active } = this.integrationForm;
const customMappingVariables = {
payloadAttributeMappings: this.mapping,
payloadExample: this.samplePayload.json || '{}',
};
- const variables = this.isHttp
- ? { name, active, ...customMappingVariables }
- : { apiUrl, active };
+ const variables = this.isHttp ? { name, active, ...customMappingVariables } : { active };
return { type: this.integrationForm.type, variables };
},
testAlertModal() {
return this.isFormDirty ? testAlertModalId : null;
},
- prometheusUrlInvalidFeedback() {
- const { blankUrlError, invalidUrlError } = i18n.integrationFormSteps.prometheusFormUrl;
- return this.integrationForm.apiUrl?.length ? invalidUrlError : blankUrlError;
- },
},
watch: {
tabIndex(val) {
@@ -246,13 +233,12 @@ export default {
type,
active,
url,
- apiUrl,
token,
payloadExample,
payloadAlertFields,
payloadAttributeMappings,
} = val;
- this.integrationForm = { type, name, active, url, apiUrl, token };
+ this.integrationForm = { type, name, active, url, token };
if (this.showMappingBuilder) {
this.resetPayloadAndMappingConfirmed = false;
@@ -270,14 +256,6 @@ export default {
validateName() {
this.validationState.name = Boolean(this.integrationForm.name?.length);
},
- validateApiUrl() {
- try {
- const parsedUrl = new URL(this.integrationForm.apiUrl);
- this.validationState.apiUrl = ['http:', 'https:'].includes(parsedUrl.protocol);
- } catch (e) {
- this.validationState.apiUrl = false;
- }
- },
isValidNonEmptyJSON(JSONString) {
if (JSONString) {
let parsed;
@@ -297,14 +275,12 @@ export default {
},
triggerValidation() {
if (this.isHttp) {
- this.validationState.apiUrl = true;
this.validateName();
if (!this.validationState.name) {
this.$refs.integrationName.$el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} else if (this.isPrometheus) {
this.validationState.name = true;
- this.validateApiUrl();
}
},
sendTestAlert() {
@@ -331,7 +307,6 @@ export default {
this.integrationForm.type = integrationTypes.none.value;
this.integrationForm.name = '';
this.integrationForm.active = false;
- this.integrationForm.apiUrl = '';
this.samplePayload = {
json: null,
error: null,
@@ -489,28 +464,6 @@ export default {
class="gl-mt-4 gl-font-weight-normal"
/>
</gl-form-group>
-
- <gl-form-group
- v-if="isPrometheus"
- class="gl-my-4"
- :label="$options.i18n.integrationFormSteps.prometheusFormUrl.label"
- label-for="api-url"
- :invalid-feedback="prometheusUrlInvalidFeedback"
- :state="validationState.apiUrl"
- >
- <gl-form-input
- id="api-url"
- v-model="integrationForm.apiUrl"
- type="text"
- :placeholder="$options.placeholders.prometheus"
- data-qa-selector="prometheus_url_field"
- @input="validateApiUrl"
- />
- <span class="gl-text-gray-400">
- {{ $options.i18n.integrationFormSteps.prometheusFormUrl.help }}
- </span>
- </gl-form-group>
-
<template v-if="showMappingBuilder">
<gl-form-group
data-testid="sample-payload-section"
@@ -617,7 +570,7 @@ export default {
>
<alert-settings-form-help-block
:message="viewCredentialsHelpMsg"
- link="https://docs.gitlab.com/ee/operations/incident_management/integrations.html#configuration"
+ :link="$options.incidentManagementDocsLink"
/>
<gl-form-group id="integration-webhook">
diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js
index 6d914fe8361..218b09cb1b6 100644
--- a/app/assets/javascripts/alerts_settings/constants.js
+++ b/app/assets/javascripts/alerts_settings/constants.js
@@ -65,12 +65,6 @@ export const i18n = {
proceedWithoutSave: s__('AlertSettings|Send without saving'),
cancel: __('Cancel'),
},
- prometheusFormUrl: {
- label: s__('AlertSettings|Prometheus API base URL'),
- help: s__('AlertSettings|URL cannot be blank and must start with http: or https:.'),
- blankUrlError: __('URL cannot be blank'),
- invalidUrlError: __('URL is invalid'),
- },
restKeyInfo: {
label: s__(
'AlertSettings|If you reset the authorization key for this project, you must update the key in every enabled alert source.',
diff --git a/app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql b/app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql
index 6d9307959df..2d8430dbede 100644
--- a/app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql
@@ -5,5 +5,4 @@ fragment IntegrationItem on AlertManagementIntegration {
name
url
token
- apiUrl
}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql
index bb22795ddd5..c2acd928c5c 100644
--- a/app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql
@@ -1,9 +1,7 @@
#import "../fragments/integration_item.fragment.graphql"
-mutation createPrometheusIntegration($projectPath: ID!, $apiUrl: String!, $active: Boolean!) {
- prometheusIntegrationCreate(
- input: { projectPath: $projectPath, apiUrl: $apiUrl, active: $active }
- ) {
+mutation createPrometheusIntegration($projectPath: ID!, $active: Boolean!) {
+ prometheusIntegrationCreate(input: { projectPath: $projectPath, active: $active }) {
errors
integration {
...IntegrationItem
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue
index 33d6eb139f7..92649477922 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue
@@ -78,6 +78,7 @@ export default {
title: TOKEN_TITLE_AUTHOR,
type: TOKEN_TYPE_AUTHOR,
token: UserToken,
+ dataType: 'user',
initialUsers: this.authorsData,
unique: true,
operators: OPERATORS_IS,
@@ -88,6 +89,7 @@ export default {
title: TOKEN_TITLE_ASSIGNEE,
type: TOKEN_TYPE_ASSIGNEE,
token: UserToken,
+ dataType: 'user',
initialUsers: this.assigneesData,
unique: false,
operators: OPERATORS_IS,
diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js
index 25699c17b10..7ec7eac24ec 100644
--- a/app/assets/javascripts/analytics/shared/constants.js
+++ b/app/assets/javascripts/analytics/shared/constants.js
@@ -39,8 +39,8 @@ export const DORA_METRICS = {
};
const VSA_FLOW_METRICS_GROUP = {
- key: 'key_metrics',
- title: s__('ValueStreamAnalytics|Key metrics'),
+ key: 'lifecycle_metrics',
+ title: s__('ValueStreamAnalytics|Lifecycle metrics'),
keys: Object.values(FLOW_METRICS),
};
diff --git a/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue
index dfe94aeb884..06b83c87985 100644
--- a/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue
@@ -49,11 +49,13 @@ export default {
return data.users?.nodes || [];
},
result({ data }) {
- const {
- users: { pageInfo },
- } = data;
- this.pageInfo = pageInfo;
- this.fetchNextPage();
+ if (data) {
+ const {
+ users: { pageInfo },
+ } = data;
+ this.pageInfo = pageInfo;
+ this.fetchNextPage();
+ }
},
error(error) {
this.handleError(error);
diff --git a/app/assets/javascripts/api/groups_api.js b/app/assets/javascripts/api/groups_api.js
index 1b216e6f721..f9edebb9141 100644
--- a/app/assets/javascripts/api/groups_api.js
+++ b/app/assets/javascripts/api/groups_api.js
@@ -18,10 +18,10 @@ const axiosGet = (url, query, options, callback) => {
...options,
},
})
- .then(({ data }) => {
+ .then(({ data, headers }) => {
callback(data);
- return data;
+ return { data, headers };
});
};
diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js
index 17ad1a0b31d..c056b42b5b6 100644
--- a/app/assets/javascripts/api/user_api.js
+++ b/app/assets/javascripts/api/user_api.js
@@ -11,6 +11,7 @@ const USER_POST_STATUS_PATH = '/api/:version/user/status';
const USER_FOLLOW_PATH = '/api/:version/users/:id/follow';
const USER_UNFOLLOW_PATH = '/api/:version/users/:id/unfollow';
const USER_FOLLOWERS_PATH = '/api/:version/users/:id/followers';
+const USER_FOLLOWING_PATH = '/api/:version/users/:id/following';
const USER_ASSOCIATIONS_COUNT_PATH = '/api/:version/users/:id/associations_count';
export function getUsers(query, options) {
@@ -82,6 +83,16 @@ export function getUserFollowers(userId, params) {
});
}
+export function getUserFollowing(userId, params) {
+ const url = buildApiUrl(USER_FOLLOWING_PATH).replace(':id', encodeURIComponent(userId));
+ return axios.get(url, {
+ params: {
+ per_page: DEFAULT_PER_PAGE,
+ ...params,
+ },
+ });
+}
+
export function associationsCount(userId) {
const url = buildApiUrl(USER_ASSOCIATIONS_COUNT_PATH).replace(':id', encodeURIComponent(userId));
return axios.get(url);
diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
index e6c3a0cba58..96889f0059c 100644
--- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
@@ -1,10 +1,14 @@
<script>
import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
import { mapGetters, mapActions, mapState } from 'vuex';
+import { __ } from '~/locale';
import { createAlert } from '~/alert';
-import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
-import Autosave from '~/autosave';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { CLEAR_AUTOSAVE_ENTRY_EVENT } from '~/vue_shared/constants';
+import markdownEditorEventHub from '~/vue_shared/components/markdown/eventhub';
+import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking';
export default {
components: {
@@ -14,9 +18,10 @@ export default {
GlForm,
GlFormGroup,
GlFormCheckbox,
- MarkdownField,
+ MarkdownEditor,
ApprovalPassword: () => import('ee_component/batch_comments/components/approval_password.vue'),
},
+ mixins: [glFeatureFlagsMixin()],
data() {
return {
isSubmitting: false,
@@ -27,11 +32,24 @@ export default {
approve: false,
approval_password: '',
},
+ formFieldProps: {
+ id: 'review-note-body',
+ name: 'review[note]',
+ placeholder: __('Write a comment or drag your files here…'),
+ 'aria-label': __('Comment'),
+ 'data-testid': 'comment-textarea',
+ },
};
},
computed: {
...mapGetters(['getNotesData', 'getNoteableData', 'noteableType', 'getCurrentUserLastNote']),
...mapState('batchComments', ['shouldAnimateReviewButton']),
+ autocompleteDataSources() {
+ return gl.GfmAutoComplete?.dataSources;
+ },
+ autosaveKey() {
+ return `submit_review_dropdown/${this.getNoteableData.id}`;
+ },
},
watch: {
'noteData.approve': function noteDataApproveWatch() {
@@ -41,10 +59,6 @@ export default {
},
},
mounted() {
- this.autosave = new Autosave(
- this.$refs.textarea,
- `submit_review_dropdown/${this.getNoteableData.id}`,
- );
this.noteData.noteable_type = this.noteableType;
this.noteData.noteable_id = this.getNoteableData.id;
@@ -67,10 +81,12 @@ export default {
async submitReview() {
this.isSubmitting = true;
+ trackSavedUsingEditor(this.$refs.markdownEditor.isContentEditorActive, 'MergeRequest_review');
+
try {
await this.publishReview(this.noteData);
- this.autosave.reset();
+ markdownEditorEventHub.$emit(CLEAR_AUTOSAVE_ENTRY_EVENT, this.descriptionAutosaveKey);
if (window.mrTabs && (this.noteData.note || this.noteData.approve)) {
if (this.noteData.note) {
@@ -117,37 +133,26 @@ export default {
{{ __('Summary comment (optional)') }}
</template>
<div class="common-note-form gfm-form">
- <div class="comment-warning-wrapper-large gl-border-0 gl-bg-white gl-overflow-hidden">
- <markdown-field
- :is-submitting="isSubmitting"
- :add-spacing-classes="false"
- :textarea-value="noteData.note"
- :markdown-preview-path="getNoteableData.preview_note_path"
- :markdown-docs-path="getNotesData.markdownDocsPath"
- :quick-actions-docs-path="getNotesData.quickActionsDocsPath"
- :restricted-tool-bar-items="$options.restrictedToolbarItems"
- :force-autosize="false"
- class="js-no-autosize"
- >
- <template #textarea>
- <textarea
- id="review-note-body"
- ref="textarea"
- v-model="noteData.note"
- dir="auto"
- :disabled="isSubmitting"
- name="review[note]"
- class="note-textarea js-gfm-input markdown-area"
- data-supports-quick-actions="true"
- data-testid="comment-textarea"
- :aria-label="__('Comment')"
- :placeholder="__('Write a comment or drag your files here…')"
- @keydown.meta.enter="submitReview"
- @keydown.ctrl.enter="submitReview"
- ></textarea>
- </template>
- </markdown-field>
- </div>
+ <markdown-editor
+ ref="markdownEditor"
+ v-model="noteData.note"
+ :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)"
+ class="js-no-autosize"
+ :is-submitting="isSubmitting"
+ :render-markdown-path="getNoteableData.preview_note_path"
+ :markdown-docs-path="getNotesData.markdownDocsPath"
+ :form-field-props="formFieldProps"
+ enable-autocomplete
+ :autocomplete-data-sources="autocompleteDataSources"
+ :disabled="isSubmitting"
+ :restricted-tool-bar-items="$options.restrictedToolbarItems"
+ :force-autosize="false"
+ :autosave-key="autosaveKey"
+ supports-quick-actions
+ @input="$emit('input', $event)"
+ @keydown.meta.enter="submitReview"
+ @keydown.ctrl.enter="submitReview"
+ />
</div>
</gl-form-group>
<template v-if="getNoteableData.current_user.can_approve">
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
index 45e7256a734..070ce38c8aa 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
@@ -3,6 +3,7 @@ import { createAlert } from '~/alert';
import { scrollToElement } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import { FILE_DIFF_POSITION_TYPE } from '~/diffs/constants';
+import { updateNoteErrorMessage } from '~/notes/utils';
import { CHANGES_TAB, DISCUSSION_TAB, SHOW_TAB } from '../../../constants';
import service from '../../../services/drafts_service';
import * as types from './mutation_types';
@@ -18,10 +19,8 @@ export const addDraftToDiscussion = ({ commit }, { endpoint, data }) =>
commit(types.ADD_NEW_DRAFT, res);
return res;
})
- .catch(() => {
- createAlert({
- message: __('An error occurred adding a draft to the thread.'),
- });
+ .catch((e) => {
+ throw e.response;
});
export const createNewDraft = ({ commit, dispatch }, { endpoint, data }) =>
@@ -37,10 +36,8 @@ export const createNewDraft = ({ commit, dispatch }, { endpoint, data }) =>
return res;
})
- .catch(() => {
- createAlert({
- message: __('An error occurred adding a new draft.'),
- });
+ .catch((e) => {
+ throw e.response;
});
export const deleteDraft = ({ commit, getters }, draft) =>
@@ -113,7 +110,7 @@ export const updateDiscussionsAfterPublish = async ({ dispatch, getters, rootGet
export const updateDraft = (
{ commit, getters },
- { note, noteText, resolveDiscussion, position, callback },
+ { note, noteText, resolveDiscussion, position, flashContainer, callback, errorCallback },
) => {
const params = {
draftId: note.id,
@@ -129,11 +126,14 @@ export const updateDraft = (
.then((res) => res.data)
.then((data) => commit(types.RECEIVE_DRAFT_UPDATE_SUCCESS, data))
.then(callback)
- .catch(() =>
+ .catch((e) => {
createAlert({
- message: __('An error occurred while updating the comment'),
- }),
- );
+ message: updateNoteErrorMessage(e),
+ parent: flashContainer,
+ });
+
+ errorCallback();
+ });
};
export const scrollToDraft = ({ dispatch, rootGetters }, draft) => {
diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js
index 29204020058..8849e9f7a11 100644
--- a/app/assets/javascripts/behaviors/gl_emoji.js
+++ b/app/assets/javascripts/behaviors/gl_emoji.js
@@ -1,4 +1,10 @@
-import { initEmojiMap, getEmojiInfo, emojiFallbackImageSrc, emojiImageTag } from '../emoji';
+import {
+ initEmojiMap,
+ getEmojiInfo,
+ emojiFallbackImageSrc,
+ emojiImageTag,
+ findCustomEmoji,
+} from '../emoji';
import isEmojiUnicodeSupported from '../emoji/support';
class GlEmoji extends HTMLElement {
@@ -33,6 +39,7 @@ class GlEmoji extends HTMLElement {
this.childNodes &&
Array.prototype.every.call(this.childNodes, (childNode) => childNode.nodeType === 3);
+ const customEmoji = findCustomEmoji(name);
const hasImageFallback = fallbackSrc?.length > 0;
const hasCssSpriteFallback = fallbackSpriteClass?.length > 0;
@@ -51,7 +58,7 @@ class GlEmoji extends HTMLElement {
this.classList.add(fallbackSpriteClass);
} else if (hasImageFallback) {
this.innerHTML = '';
- this.appendChild(emojiImageTag(name, fallbackSrc));
+ this.appendChild(emojiImageTag(name, customEmoji?.src || fallbackSrc));
} else {
const src = emojiFallbackImageSrc(name);
this.innerHTML = '';
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index 39a7a76e91f..333858f717c 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -3,13 +3,12 @@ import highlightCurrentUser from './highlight_current_user';
import { renderKroki } from './render_kroki';
import renderMath from './render_math';
import renderSandboxedMermaid from './render_sandboxed_mermaid';
-import renderMetrics from './render_metrics';
import renderObservability from './render_observability';
import { renderJSONTable } from './render_json_table';
function initPopovers(elements) {
if (!elements.length) return;
- import(/* webpackChunkName: 'IssuablePopoverBundle' */ '~/issuable/popover')
+ import(/* webpackChunkName: 'IssuablePopoverBundle' */ 'ee_else_ce/issuable/popover')
.then(({ default: initIssuablePopovers }) => {
initIssuablePopovers(elements);
})
@@ -30,7 +29,6 @@ export function renderGFM(element) {
tableEls,
userEls,
popoverEls,
- metricsEls,
observabilityEls,
] = [
'.js-syntax-highlight',
@@ -39,8 +37,7 @@ export function renderGFM(element) {
'.js-render-mermaid',
'[lang="json"][data-lang-params="table"]',
'.gfm-project_member',
- '.gfm-issue, .gfm-work_item, .gfm-merge_request',
- '.js-render-metrics',
+ '.gfm-issue, .gfm-work_item, .gfm-merge_request, .gfm-epic',
'.js-render-observability',
].map((selector) => Array.from(element.querySelectorAll(selector)));
@@ -50,9 +47,6 @@ export function renderGFM(element) {
renderSandboxedMermaid(mermaidEls);
renderJSONTable(tableEls.map((e) => e.parentNode));
highlightCurrentUser(userEls);
- if (!window.gon?.features?.removeMonitorMetrics) {
- renderMetrics(metricsEls);
- }
renderObservability(observabilityEls);
initPopovers(popoverEls);
}
diff --git a/app/assets/javascripts/behaviors/markdown/render_metrics.js b/app/assets/javascripts/behaviors/markdown/render_metrics.js
deleted file mode 100644
index e7a2a6ce47c..00000000000
--- a/app/assets/javascripts/behaviors/markdown/render_metrics.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import Vue from 'vue';
-import { createStore } from '~/monitoring/stores/embed_group/';
-
-// TODO: Handle copy-pasting - https://gitlab.com/gitlab-org/gitlab-foss/issues/64369.
-export default function renderMetrics(elements) {
- if (!elements.length) {
- return Promise.resolve();
- }
-
- const wrapperList = [];
-
- elements.forEach((element) => {
- let wrapper;
- const { previousElementSibling } = element;
- const isFirstElementInGroup = !previousElementSibling?.urls;
-
- if (isFirstElementInGroup) {
- wrapper = document.createElement('div');
- wrapper.urls = [element.dataset.dashboardUrl];
- element.parentNode.insertBefore(wrapper, element);
- wrapperList.push(wrapper);
- } else {
- wrapper = previousElementSibling;
- wrapper.urls.push(element.dataset.dashboardUrl);
- }
-
- // Clean up processed element
- element.parentNode.removeChild(element);
- });
-
- return import(
- /* webpackChunkName: 'gfm_metrics' */ '~/monitoring/components/embeds/embed_group.vue'
- ).then(({ default: EmbedGroup }) => {
- const EmbedGroupComponent = Vue.extend(EmbedGroup);
-
- wrapperList.forEach((wrapper) => {
- // eslint-disable-next-line no-new
- new EmbedGroupComponent({
- el: wrapper,
- store: createStore(),
- propsData: {
- urls: wrapper.urls,
- },
- });
- });
- });
-}
diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js
index bcd92d09033..ce77ede9fe4 100644
--- a/app/assets/javascripts/behaviors/preview_markdown.js
+++ b/app/assets/javascripts/behaviors/preview_markdown.js
@@ -139,7 +139,7 @@ $(document).on('markdown-preview:show', (e, $form) => {
// toggle content
$form.find('.md-write-holder').hide();
$form.find('.md-preview-holder').show();
- $form.find('.md-header-toolbar, .js-zen-enter').addClass('gl-display-none!');
+ $form.find('.haml-markdown-button, .js-zen-enter').addClass('gl-display-none!');
markdownPreview.showPreview($form);
});
@@ -162,7 +162,7 @@ $(document).on('markdown-preview:hide', (e, $form) => {
$form.find('.md-write-holder').show();
$form.find('textarea.markdown-area').focus();
$form.find('.md-preview-holder').hide();
- $form.find('.md-header-toolbar, .js-zen-enter').removeClass('gl-display-none!');
+ $form.find('.haml-markdown-button, .js-zen-enter').removeClass('gl-display-none!');
markdownPreview.hideReferencedCommands($form);
});
diff --git a/app/assets/javascripts/blob/line_highlighter.js b/app/assets/javascripts/blob/line_highlighter.js
index a8932f8c73b..1ec204b4034 100644
--- a/app/assets/javascripts/blob/line_highlighter.js
+++ b/app/assets/javascripts/blob/line_highlighter.js
@@ -1,6 +1,5 @@
/* eslint-disable func-names, no-underscore-dangle, no-param-reassign, consistent-return */
-import $ from 'jquery';
import { scrollToElement } from '~/lib/utils/common_utils';
// LineHighlighter
@@ -52,11 +51,12 @@ const LineHighlighter = function (options = {}) {
};
LineHighlighter.prototype.bindEvents = function () {
- const $fileHolder = $(this.options.fileHolderSelector);
-
- $fileHolder.on('click', 'a[data-line-number]', this.clickHandler);
- $fileHolder.on('highlight:line', this.highlightHash);
- window.addEventListener('hashchange', (e) => this.highlightHash(e.target.location.hash));
+ const fileHolder = document.querySelector(this.options.fileHolderSelector);
+ if (fileHolder) {
+ fileHolder.addEventListener('click', this.clickHandler);
+ fileHolder.addEventListener('highlight:line', this.highlightHash);
+ window.addEventListener('hashchange', (e) => this.highlightHash(e.target.location.hash));
+ }
};
LineHighlighter.prototype.highlightHash = function (newHash) {
@@ -82,29 +82,35 @@ LineHighlighter.prototype.highlightHash = function (newHash) {
};
LineHighlighter.prototype.clickHandler = function (event) {
- let range;
- event.preventDefault();
- this.clearHighlight();
- const lineNumber = $(event.target).closest('a').data('lineNumber');
- const current = this.hashToRange(this._hash);
- if (!(current[0] && event.shiftKey)) {
- // If there's no current selection, or there is but Shift wasn't held,
- // treat this like a single-line selection.
- this.setHash(lineNumber);
- return this.highlightLine(lineNumber);
- } else if (event.shiftKey) {
- if (lineNumber < current[0]) {
- range = [lineNumber, current[0]];
- } else {
- range = [current[0], lineNumber];
+ const isLine = event.target.matches('a[data-line-number]');
+ if (isLine) {
+ let range;
+ event.preventDefault();
+ this.clearHighlight();
+ const lineNumber = parseInt(event.target.dataset.lineNumber, 10);
+ const current = this.hashToRange(this._hash);
+ if (!(current[0] && event.shiftKey)) {
+ // If there's no current selection, or there is but Shift wasn't held,
+ // treat this like a single-line selection.
+ this.setHash(lineNumber);
+ return this.highlightLine(lineNumber);
+ } else if (event.shiftKey) {
+ if (lineNumber < current[0]) {
+ range = [lineNumber, current[0]];
+ } else {
+ range = [current[0], lineNumber];
+ }
+ this.setHash(range[0], range[1]);
+ return this.highlightRange(range);
}
- this.setHash(range[0], range[1]);
- return this.highlightRange(range);
}
};
LineHighlighter.prototype.clearHighlight = function () {
- return $(`.${this.highlightLineClass}`).removeClass(this.highlightLineClass);
+ const highlightedLines = document.getElementsByClassName(this.highlightLineClass);
+ Array.from(highlightedLines).forEach(function (line) {
+ line.classList.remove(this.highlightLineClass);
+ }, this);
};
// Convert a URL hash String into line numbers
@@ -133,7 +139,10 @@ LineHighlighter.prototype.hashToRange = function (hash) {
//
// lineNumber - Line number to highlight
LineHighlighter.prototype.highlightLine = function (lineNumber) {
- return $(`#LC${lineNumber}`).addClass(this.highlightLineClass);
+ const lineElement = document.getElementById(`LC${lineNumber}`);
+ if (lineElement) {
+ lineElement.classList.add(this.highlightLineClass);
+ }
};
// Highlight all lines within a range
@@ -144,7 +153,7 @@ LineHighlighter.prototype.highlightRange = function (range) {
const results = [];
const ref = range[0] <= range[1] ? range : range.reverse();
- for (let lineNumber = range[0]; lineNumber <= ref[1]; lineNumber += 1) {
+ for (let lineNumber = ref[0]; lineNumber <= ref[1]; lineNumber += 1) {
results.push(this.highlightLine(lineNumber));
}
diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue
index 0b9243c07c5..ca8299ddf80 100644
--- a/app/assets/javascripts/boards/components/board_app.vue
+++ b/app/assets/javascripts/boards/components/board_app.vue
@@ -5,9 +5,11 @@ import { s__ } from '~/locale';
import BoardContent from '~/boards/components/board_content.vue';
import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue';
import BoardTopBar from '~/boards/components/board_top_bar.vue';
+import eventHub from '~/boards/eventhub';
import { listsQuery } from 'ee_else_ce/boards/constants';
import { formatBoardLists } from 'ee_else_ce/boards/boards_util';
import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
+import errorQuery from '../graphql/client/error.query.graphql';
export default {
i18n: {
@@ -38,6 +40,7 @@ export default {
addColumnFormVisible: false,
isShowingEpicsSwimlanes: Boolean(queryToObject(window.location.search).group_by),
apolloError: null,
+ error: null,
};
},
apollo: {
@@ -75,6 +78,10 @@ export default {
this.apolloError = this.$options.i18n.fetchError;
},
},
+ error: {
+ query: errorQuery,
+ update: (data) => data.boardsAppError,
+ },
},
computed: {
@@ -106,11 +113,16 @@ export default {
},
created() {
window.addEventListener('popstate', refreshCurrentPage);
+ eventHub.$on('updateBoard', this.refetchLists);
},
destroyed() {
window.removeEventListener('popstate', refreshCurrentPage);
+ eventHub.$off('updateBoard', this.refetchLists);
},
methods: {
+ refetchLists() {
+ this.$apollo.queries.boardListsApollo.refetch();
+ },
setActiveId(id) {
this.activeListId = id;
},
@@ -145,7 +157,7 @@ export default {
:is-swimlanes-on="isSwimlanesOn"
:filter-params="filterParams"
:board-lists-apollo="boardListsApollo"
- :apollo-error="apolloError"
+ :apollo-error="apolloError || error"
:list-query-variables="listQueryVariables"
@setActiveList="setActiveId"
@setAddColumnFormVisibility="addColumnFormVisible = $event"
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index befd04c29ae..6036f0c359c 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -43,7 +43,14 @@ export default {
GlTooltip: GlTooltipDirective,
},
mixins: [boardCardInner],
- inject: ['rootPath', 'scopedLabelsAvailable', 'isEpicBoard', 'issuableType', 'isGroupBoard'],
+ inject: [
+ 'rootPath',
+ 'scopedLabelsAvailable',
+ 'isEpicBoard',
+ 'issuableType',
+ 'isGroupBoard',
+ 'isApolloBoard',
+ ],
props: {
item: {
type: Object,
@@ -78,6 +85,9 @@ export default {
},
computed: {
...mapState(['isShowingLabels', 'allowSubEpics']),
+ isLoading() {
+ return this.item.isLoading || this.item.iid === '-1';
+ },
cappedAssignees() {
// e.g. maxRender is 4,
// Render up to all 4 assignees if there are only 4 assigness
@@ -201,7 +211,9 @@ export default {
updateHistory({
url: `${filterPath}${filter}`,
});
- this.performSearch();
+ if (!this.isApolloBoard) {
+ this.performSearch();
+ }
eventHub.$emit('updateTokens');
}
},
@@ -243,7 +255,7 @@ export default {
<a
:href="item.path || item.webUrl || ''"
:title="item.title"
- :class="{ 'gl-text-gray-400!': item.isLoading }"
+ :class="{ 'gl-text-gray-400!': isLoading }"
class="js-no-trigger gl-text-body gl-hover-text-gray-900"
@mousemove.stop
>{{ item.title }}</a
@@ -272,9 +284,9 @@ export default {
<div
class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden"
>
- <gl-loading-icon v-if="item.isLoading" size="lg" class="gl-mt-5" />
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-5" />
<span
- v-if="item.referencePath"
+ v-if="item.referencePath && !isLoading"
class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-font-sm gl-text-secondary"
:class="{ 'gl-font-base': isEpicBoard }"
>
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index a51e4ddc8f8..14c781f588f 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -4,7 +4,6 @@ import { sortBy } from 'lodash';
import produce from 'immer';
import Draggable from 'vuedraggable';
import { mapState, mapActions } from 'vuex';
-import eventHub from '~/boards/eventhub';
import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
import { defaultSortableOptions } from '~/sortable/constants';
import {
@@ -107,24 +106,15 @@ export default {
return this.canDragColumns ? options : {};
},
errorToDisplay() {
- return this.isApolloBoard ? this.apolloError : this.error;
+ return this.apolloError || this.error || null;
},
},
- created() {
- eventHub.$on('updateBoard', this.refetchLists);
- },
- beforeDestroy() {
- eventHub.$off('updateBoard', this.refetchLists);
- },
methods: {
...mapActions(['moveList', 'unsetError']),
afterFormEnters() {
const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list;
el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' });
},
- refetchLists() {
- this.$apollo.queries.boardListsApollo.refetch();
- },
highlightList(listId) {
this.highlightedLists.push(listId);
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index 604e71f5993..9ea801dc9a2 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -226,10 +226,12 @@ export default {
}
this.cancel();
- const param = getParameterByName('group_by')
- ? `?group_by=${getParameterByName('group_by')}`
- : '';
- updateHistory({ url: `${this.boardBaseUrl}/${getIdFromGraphQLId(board.id)}${param}` });
+ if (!this.isApolloBoard) {
+ const param = getParameterByName('group_by')
+ ? `?group_by=${getParameterByName('group_by')}`
+ : '';
+ updateHistory({ url: `${this.boardBaseUrl}/${getIdFromGraphQLId(board.id)}${param}` });
+ }
} catch {
this.setError({ message: this.$options.i18n.saveErrorMessage });
} finally {
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index af309ba9912..b4249c63b4d 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -22,6 +22,7 @@ import {
removeItemFromList,
updateEpicsCount,
updateIssueCountAndWeight,
+ setError,
} from '../graphql/cache_updates';
import { shouldCloneCard, moveItemVariables } from '../boards_util';
import eventHub from '../eventhub';
@@ -33,7 +34,7 @@ export default {
name: 'BoardList',
i18n: {
loading: __('Loading'),
- loadingMoreboardItems: __('Loading more'),
+ loadingMoreBoardItems: __('Loading more'),
showingAllIssues: __('Showing all issues'),
showingAllEpics: __('Showing all epics'),
},
@@ -83,6 +84,7 @@ export default {
isLoadingMore: false,
toListId: null,
toList: {},
+ addItemToListInProgress: false,
};
},
apollo: {
@@ -213,7 +215,8 @@ export default {
return !this.disabled;
},
treeRootWrapper() {
- return this.canMoveIssue && !this.listsFlags[this.list.id]?.addItemToListInProgress
+ return this.canMoveIssue &&
+ (!this.listsFlags[this.list.id]?.addItemToListInProgress || this.addItemToListInProgress)
? Draggable
: 'ul';
},
@@ -468,14 +471,14 @@ export default {
this.updateCountAndWeight({ fromListId, toListId, issuable, cache });
},
- updateCountAndWeight({ fromListId, toListId, issuable, isAddingIssue, cache }) {
+ updateCountAndWeight({ fromListId, toListId, issuable, isAddingItem, cache }) {
if (!this.isEpicBoard) {
updateIssueCountAndWeight({
fromListId,
toListId,
filterParams: this.filterParams,
issuable,
- shouldClone: isAddingIssue || this.shouldCloneCard,
+ shouldClone: isAddingItem || this.shouldCloneCard,
cache,
});
} else {
@@ -486,7 +489,7 @@ export default {
fromListId,
filterParams,
issuable,
- shouldClone: this.shouldCloneCard,
+ shouldClone: isAddingItem || this.shouldCloneCard,
cache,
});
}
@@ -538,6 +541,59 @@ export default {
},
});
},
+ async addListItem(input) {
+ this.toggleForm();
+ this.addItemToListInProgress = true;
+ try {
+ await this.$apollo.mutate({
+ mutation: listIssuablesQueries[this.issuableType].createMutation,
+ variables: {
+ input: this.isEpicBoard ? input : { ...input, moveAfterId: this.boardListItems[0]?.id },
+ withColor: this.isEpicBoard && this.glFeatures.epicColorHighlight,
+ },
+ update: (cache, { data: { createIssuable } }) => {
+ const { issuable } = createIssuable;
+ addItemToList({
+ query: listIssuablesQueries[this.issuableType].query,
+ variables: { ...this.listQueryVariables, id: this.currentList.id },
+ issuable,
+ newIndex: 0,
+ boardType: this.boardType,
+ issuableType: this.issuableType,
+ cache,
+ });
+ this.updateCountAndWeight({
+ fromListId: null,
+ toListId: this.list.id,
+ issuable,
+ isAddingItem: true,
+ cache,
+ });
+ },
+ optimisticResponse: {
+ createIssuable: {
+ errors: [],
+ issuable: {
+ ...listIssuablesQueries[this.issuableType].optimisticResponse,
+ title: input.title,
+ },
+ },
+ },
+ });
+ } catch (error) {
+ setError({
+ message: sprintf(
+ __('An error occurred while creating the %{issuableType}. Please try again.'),
+ {
+ issuableType: this.isEpicBoard ? 'epic' : 'issue',
+ },
+ ),
+ error,
+ });
+ } finally {
+ this.addItemToListInProgress = false;
+ }
+ },
},
};
</script>
@@ -556,8 +612,18 @@ export default {
>
<gl-loading-icon size="sm" />
</div>
- <board-new-issue v-if="issueCreateFormVisible" :list="list" />
- <board-new-epic v-if="epicCreateFormVisible" :list="list" />
+ <board-new-issue
+ v-if="issueCreateFormVisible"
+ :list="list"
+ :board-id="boardId"
+ @addNewIssue="addListItem"
+ />
+ <board-new-epic
+ v-if="epicCreateFormVisible"
+ :list="list"
+ :board-id="boardId"
+ @addNewEpic="addListItem"
+ />
<component
:is="treeRootWrapper"
v-show="!loading"
@@ -610,7 +676,7 @@ export default {
<gl-loading-icon
v-if="loadingMore"
size="sm"
- :label="$options.i18n.loadingMoreboardItems"
+ :label="$options.i18n.loadingMoreBoardItems"
/>
<span v-if="showingAllItems">{{ showingAllItemsText }}</span>
<span v-else>{{ paginatedIssueText }}</span>
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index 61a9b22bfc5..8db86d0e894 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -1,12 +1,12 @@
<script>
import {
GlButton,
+ GlButtonGroup,
GlLabel,
GlTooltip,
GlIcon,
GlSprintf,
GlTooltipDirective,
- GlDisclosureDropdown,
} from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { isListDraggable } from '~/boards/boards_util';
@@ -35,15 +35,14 @@ import ItemCount from './item_count.vue';
export default {
i18n: {
newIssue: s__('Boards|Create new issue'),
- listActions: s__('Boards|List actions'),
newEpic: s__('Boards|Create new epic'),
listSettings: s__('Boards|Edit list settings'),
expand: s__('Boards|Expand'),
collapse: s__('Boards|Collapse'),
},
components: {
- GlDisclosureDropdown,
GlButton,
+ GlButtonGroup,
GlLabel,
GlTooltip,
GlIcon,
@@ -194,50 +193,6 @@ export default {
canShowTotalWeight() {
return this.weightFeatureAvailable && !this.isLoading;
},
- actionListItems() {
- const items = [];
-
- if (this.isNewIssueShown) {
- const newIssueText = this.$options.i18n.newIssue;
- items.push({
- text: newIssueText,
- action: this.showNewIssueForm,
- extraAttrs: {
- 'data-testid': 'newIssueBtn',
- title: newIssueText,
- 'aria-label': newIssueText,
- },
- });
- }
-
- if (this.isNewEpicShown) {
- const newEpicText = this.$options.i18n.newEpic;
- items.push({
- text: newEpicText,
- action: this.showNewEpicForm,
- extraAttrs: {
- 'data-testid': 'newEpicBtn',
- title: newEpicText,
- 'aria-label': newEpicText,
- },
- });
- }
-
- if (this.isSettingsShown) {
- const listSettingsText = this.$options.i18n.listSettings;
- items.push({
- text: listSettingsText,
- action: this.openSidebarSettings,
- extraAttrs: {
- 'data-testid': 'settingsBtn',
- title: listSettingsText,
- 'aria-label': listSettingsText,
- },
- });
- }
-
- return items;
- },
},
apollo: {
boardList: {
@@ -525,23 +480,42 @@ export default {
<!-- EE end -->
</span>
</div>
- <gl-disclosure-dropdown
- v-if="showListHeaderActions"
- v-gl-tooltip.hover.top="{
- title: $options.i18n.listActions,
- boundary: 'viewport',
- }"
- data-testid="header-list-actions"
- class="gl-py-2 gl-ml-3"
- :aria-label="$options.i18n.listActions"
- :title="$options.i18n.listActions"
- category="tertiary"
- icon="ellipsis_v"
- :text-sr-only="true"
- :items="actionListItems"
- no-caret
- placement="right"
- />
+ <gl-button-group v-if="showListHeaderActions" class="board-list-button-group gl-pl-2">
+ <gl-button
+ v-if="isNewIssueShown"
+ ref="newIssueBtn"
+ v-gl-tooltip.hover
+ :aria-label="$options.i18n.newIssue"
+ :title="$options.i18n.newIssue"
+ size="small"
+ icon="plus"
+ data-testid="new-issue-btn"
+ @click="showNewIssueForm"
+ />
+
+ <gl-button
+ v-if="isNewEpicShown"
+ v-gl-tooltip.hover
+ :aria-label="$options.i18n.newEpic"
+ :title="$options.i18n.newEpic"
+ size="small"
+ icon="plus"
+ data-testid="new-epic-btn"
+ @click="showNewEpicForm"
+ />
+
+ <gl-button
+ v-if="isSettingsShown"
+ ref="settingsBtn"
+ v-gl-tooltip.hover
+ :aria-label="$options.i18n.listSettings"
+ size="small"
+ :title="$options.i18n.listSettings"
+ icon="settings"
+ data-testid="settings-btn"
+ @click="openSidebarSettings"
+ />
+ </gl-button-group>
</h3>
</header>
</template>
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index 8b9fafca306..b68444fb011 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -1,30 +1,73 @@
<script>
-import { mapActions, mapGetters, mapState } from 'vuex';
-import { getMilestone } from 'ee_else_ce/boards/boards_util';
+import { mapActions, mapGetters } from 'vuex';
+import { s__ } from '~/locale';
+import { getMilestone, formatIssueInput, getBoardQuery } from 'ee_else_ce/boards/boards_util';
import BoardNewIssueMixin from 'ee_else_ce/boards/mixins/board_new_issue';
import { toggleFormEventPrefix } from '../constants';
import eventHub from '../eventhub';
+import { setError } from '../graphql/cache_updates';
import BoardNewItem from './board_new_item.vue';
import ProjectSelect from './project_select.vue';
export default {
name: 'BoardNewIssue',
+ i18n: {
+ errorFetchingBoard: s__('Boards|An error occurred while fetching board. Please try again.'),
+ },
components: {
BoardNewItem,
ProjectSelect,
},
mixins: [BoardNewIssueMixin],
- inject: ['groupId', 'fullPath', 'isGroupBoard'],
+ inject: ['boardType', 'groupId', 'fullPath', 'isGroupBoard', 'isEpicBoard', 'isApolloBoard'],
props: {
list: {
type: Object,
required: true,
},
+ boardId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selectedProject: {},
+ board: {},
+ };
+ },
+ apollo: {
+ board: {
+ query() {
+ return getBoardQuery(this.boardType, this.isEpicBoard);
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ boardId: this.boardId,
+ };
+ },
+ skip() {
+ return !this.isApolloBoard;
+ },
+ update(data) {
+ const { board } = data.workspace;
+ return {
+ ...board,
+ labels: board.labels?.nodes,
+ };
+ },
+ error(error) {
+ setError({
+ error,
+ message: this.$options.i18n.errorFetchingBoard,
+ });
+ },
+ },
},
computed: {
- ...mapState(['selectedProject']),
...mapGetters(['getBoardItemsByList']),
formEventPrefix() {
return toggleFormEventPrefix.issue;
@@ -42,8 +85,20 @@ export default {
const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : [];
const milestone = getMilestone(this.list);
- const firstItemId = this.getBoardItemsByList(this.list.id)[0]?.id;
+ if (this.isApolloBoard) {
+ return this.addNewIssueToList({
+ issueInput: {
+ title,
+ labelIds: labels?.map((l) => l.id),
+ assigneeIds: assignees?.map((a) => a?.id),
+ milestoneId: milestone?.id,
+ projectPath: this.projectPath,
+ },
+ });
+ }
+
+ const firstItemId = this.getBoardItemsByList(this.list.id)[0]?.id;
return this.addListNewIssue({
list: this.list,
issueInput: {
@@ -58,6 +113,22 @@ export default {
this.cancel();
});
},
+ addNewIssueToList({ issueInput }) {
+ const { labels, assignee, milestone, weight } = this.board;
+ const config = {
+ labels,
+ assigneeId: assignee?.id || null,
+ milestoneId: milestone?.id || null,
+ weight,
+ };
+ const input = formatIssueInput(issueInput, config);
+
+ if (!this.isGroupBoard) {
+ input.projectPath = this.fullPath;
+ }
+
+ this.$emit('addNewIssue', input);
+ },
cancel() {
eventHub.$emit(`${this.formEventPrefix}${this.list.id}`);
},
@@ -74,6 +145,6 @@ export default {
@form-submit="submit"
@form-cancel="cancel"
>
- <project-select v-if="isGroupBoard" :group-id="groupId" :list="list" />
+ <project-select v-if="isGroupBoard" v-model="selectedProject" :list="list" />
</board-new-item>
</template>
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 3c056f296e1..f60f00be368 100644
--- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
@@ -75,6 +75,7 @@ export default {
type: TOKEN_TYPE_ASSIGNEE,
operators: OPERATORS_IS_NOT,
token: UserToken,
+ dataType: 'user',
unique: true,
fetchUsers,
preloadedUsers: this.preloadedUsers(),
@@ -86,6 +87,7 @@ export default {
operators: OPERATORS_IS_NOT,
symbol: '@',
token: UserToken,
+ dataType: 'user',
unique: true,
fetchUsers,
preloadedUsers: this.preloadedUsers(),
diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue
index 960c8e472b8..7bbc444701a 100644
--- a/app/assets/javascripts/boards/components/project_select.vue
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -1,11 +1,8 @@
<script>
import { GlCollapsibleListbox } from '@gitlab/ui';
-import { mapActions, mapGetters, mapState } from 'vuex';
-import { debounce } from 'lodash';
import { s__ } from '~/locale';
-import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
-import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { ListType } from '../constants';
+import groupProjectsQuery from '../graphql/group_projects.query.graphql';
+import { setError } from '../graphql/cache_updates';
export default {
name: 'ProjectSelect',
@@ -14,6 +11,9 @@ export default {
dropdownText: s__(`BoardNewIssue|Select a project`),
searchPlaceholder: s__(`BoardNewIssue|Search projects`),
emptySearchResult: s__(`BoardNewIssue|No matching results`),
+ errorFetchingProjects: s__(
+ 'Boards|An error occurred while fetching group projects. Please try again.',
+ ),
},
defaultFetchOptions: {
with_issues_enabled: true,
@@ -24,70 +24,107 @@ export default {
components: {
GlCollapsibleListbox,
},
- inject: ['groupId'],
+ inject: ['groupId', 'fullPath'],
+ model: {
+ prop: 'selectedProject',
+ event: 'selectProject',
+ },
props: {
list: {
type: Object,
required: true,
},
+ selectedProject: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
initialLoading: true,
selectedProjectId: '',
- selectedProject: {},
searchTerm: '',
+ projects: {},
+ isLoadingMore: false,
};
},
+ apollo: {
+ projects: {
+ query: groupProjectsQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ search: this.searchTerm,
+ };
+ },
+ update(data) {
+ return data.group.projects;
+ },
+ error(error) {
+ setError({
+ error,
+ message: this.$options.i18n.errorFetchingProjects,
+ });
+ },
+ result() {
+ this.initialLoading = false;
+ },
+ },
+ },
computed: {
- ...mapState(['groupProjectsFlags']),
- ...mapGetters(['activeGroupProjects']),
- projects() {
- return this.activeGroupProjects.map((project) => ({
- value: project.id,
- text: project.nameWithNamespace,
- }));
+ isLoading() {
+ return this.$apollo.queries.projects.loading && !this.isLoadingMore;
+ },
+ activeGroupProjects() {
+ return (
+ this.projects?.nodes?.map((project) => ({
+ value: project.id,
+ text: project.nameWithNamespace,
+ })) || []
+ );
},
selectedProjectName() {
return this.selectedProject.name || this.$options.i18n.dropdownText;
},
- fetchOptions() {
- const additionalAttrs = {};
- if (this.list.type && this.list.type !== ListType.backlog) {
- additionalAttrs.min_access_level = featureAccessLevel.EVERYONE;
- }
-
- return {
- ...this.$options.defaultFetchOptions,
- ...additionalAttrs,
- };
- },
isFetchResultEmpty() {
return this.activeGroupProjects.length === 0;
},
hasNextPage() {
- return this.groupProjectsFlags.pageInfo?.hasNextPage;
+ return this.projects.pageInfo?.hasNextPage;
},
},
watch: {
- searchTerm: debounce(function debouncedSearch() {
- this.fetchGroupProjects({ search: this.searchTerm });
- }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
- },
- mounted() {
- this.fetchGroupProjects({});
- this.initialLoading = false;
+ endCursor() {
+ return this.projects.pageInfo?.endCursor;
+ },
},
methods: {
- ...mapActions(['fetchGroupProjects', 'setSelectedProject']),
selectProject(projectId) {
this.selectedProjectId = projectId;
- this.selectedProject = this.activeGroupProjects.find((project) => project.id === projectId);
- this.setSelectedProject(this.selectedProject);
+ this.$emit(
+ 'selectProject',
+ this.projects.nodes.find((project) => project.id === projectId),
+ );
},
- loadMoreProjects() {
+ async loadMoreProjects() {
if (!this.hasNextPage) return;
- this.fetchGroupProjects({ search: this.searchTerm, fetchNext: true });
+ this.isLoadingMore = true;
+ try {
+ await this.$apollo.queries.projects.fetchMore({
+ variables: {
+ fullPath: this.fullPath,
+ search: this.searchTerm,
+ after: this.endCursor,
+ },
+ });
+ } catch (error) {
+ setError({
+ error,
+ message: this.$options.i18n.errorFetchingProjects,
+ });
+ } finally {
+ this.isLoadingMore = false;
+ }
},
onSearch(query) {
this.searchTerm = query;
@@ -107,14 +144,14 @@ export default {
searchable
infinite-scroll
data-testid="project-select-dropdown"
- :items="projects"
+ :items="activeGroupProjects"
:toggle-text="selectedProjectName"
:header-text="$options.i18n.headerTitle"
:loading="initialLoading"
- :searching="groupProjectsFlags.isLoading"
+ :searching="isLoading"
:search-placeholder="$options.i18n.searchPlaceholder"
:no-results-text="$options.i18n.emptySearchResult"
- :infinite-scroll-loading="groupProjectsFlags.isLoadingMore"
+ :infinite-scroll-loading="isLoadingMore"
@select="selectProject"
@search="onSearch"
@bottom-reached="loadMoreProjects"
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index d4d1bc7804e..cb607e5220e 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -1,6 +1,7 @@
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
import { TYPE_EPIC, TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { s__, __ } from '~/locale';
+import { TYPENAME_ISSUE } from '~/graphql_shared/constants';
import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql';
import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql';
import createBoardListMutation from './graphql/board_list_create.mutation.graphql';
@@ -11,6 +12,7 @@ import toggleListCollapsedMutation from './graphql/client/board_toggle_collapsed
import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql';
import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql';
import issueMoveListMutation from './graphql/issue_move_list.mutation.graphql';
+import issueCreateMutation from './graphql/issue_create.mutation.graphql';
import groupBoardQuery from './graphql/group_board.query.graphql';
import projectBoardQuery from './graphql/project_board.query.graphql';
import listIssuesQuery from './graphql/lists_issues.query.graphql';
@@ -126,6 +128,30 @@ export const listIssuablesQueries = {
[TYPE_ISSUE]: {
query: listIssuesQuery,
moveMutation: issueMoveListMutation,
+ createMutation: issueCreateMutation,
+ optimisticResponse: {
+ assignees: { nodes: [], __typename: 'UserCoreConnection' },
+ confidential: false,
+ dueDate: null,
+ emailsDisabled: false,
+ hidden: false,
+ humanTimeEstimate: null,
+ humanTotalTimeSpent: null,
+ id: 'gid://gitlab/Issue/-1',
+ iid: '-1',
+ labels: { nodes: [], __typename: 'LabelConnection' },
+ milestone: null,
+ referencePath: '',
+ relativePosition: null,
+ severity: 'UNKNOWN',
+ timeEstimate: 0,
+ title: '',
+ totalTimeSpent: 0,
+ type: 'ISSUE',
+ webUrl: '',
+ weight: null,
+ __typename: TYPENAME_ISSUE,
+ },
},
};
diff --git a/app/assets/javascripts/boards/graphql/cache_updates.js b/app/assets/javascripts/boards/graphql/cache_updates.js
index 084809e4e60..e54701a63c0 100644
--- a/app/assets/javascripts/boards/graphql/cache_updates.js
+++ b/app/assets/javascripts/boards/graphql/cache_updates.js
@@ -1,11 +1,26 @@
+import * as Sentry from '@sentry/browser';
import produce from 'immer';
+import { defaultClient } from '~/graphql_shared/issuable_client';
import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
import { listsDeferredQuery } from 'ee_else_ce/boards/constants';
-export function removeItemFromList({ query, variables, boardType, id, issuableType, cache }) {
+import setErrorMutation from './client/set_error.mutation.graphql';
+
+export function removeItemFromList({
+ query,
+ variables,
+ boardType,
+ id,
+ issuableType,
+ listId = undefined,
+ cache,
+}) {
cache.updateQuery({ query, variables }, (sourceData) =>
produce(sourceData, (draftData) => {
- const { nodes: items } = draftData[boardType].board.lists.nodes[0][`${issuableType}s`];
+ const list = listId
+ ? draftData[boardType]?.board.lists.nodes.find((l) => l.id === listId)
+ : draftData[boardType].board.lists.nodes[0];
+ const { nodes: items } = list[`${issuableType}s`];
items.splice(
items.findIndex((item) => item.id === id),
1,
@@ -21,11 +36,15 @@ export function addItemToList({
issuable,
newIndex,
issuableType,
+ listId = undefined,
cache,
}) {
cache.updateQuery({ query, variables }, (sourceData) =>
produce(sourceData, (draftData) => {
- const { nodes: items } = draftData[boardType].board.lists.nodes[0][`${issuableType}s`];
+ const list = listId
+ ? draftData[boardType]?.board.lists.nodes.find((l) => l.id === listId)
+ : draftData[boardType].board.lists.nodes[0];
+ const { nodes: items } = list[`${issuableType}s`];
items.splice(newIndex, 0, issuable);
}),
);
@@ -116,3 +135,16 @@ export function updateEpicsCount({
}),
);
}
+
+export function setError({ message, error, captureError = true }) {
+ defaultClient.mutate({
+ mutation: setErrorMutation,
+ variables: {
+ error: message,
+ },
+ });
+
+ if (captureError) {
+ Sentry.captureException(error);
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/client/error.query.graphql b/app/assets/javascripts/boards/graphql/client/error.query.graphql
new file mode 100644
index 00000000000..56f2588f3b9
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/client/error.query.graphql
@@ -0,0 +1,3 @@
+query boardsAppError {
+ boardsAppError @client
+}
diff --git a/app/assets/javascripts/boards/graphql/client/set_error.mutation.graphql b/app/assets/javascripts/boards/graphql/client/set_error.mutation.graphql
new file mode 100644
index 00000000000..56fc592d21b
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/client/set_error.mutation.graphql
@@ -0,0 +1,3 @@
+mutation setError($error: String!) {
+ setError(error: $error) @client
+}
diff --git a/app/assets/javascripts/boards/graphql/issue_create.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_create.mutation.graphql
index 643d5dcfe4c..55cb34c0930 100644
--- a/app/assets/javascripts/boards/graphql/issue_create.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/issue_create.mutation.graphql
@@ -1,8 +1,8 @@
#import "ee_else_ce/boards/graphql/issue.fragment.graphql"
mutation CreateIssue($input: CreateIssueInput!) {
- createIssue(input: $input) {
- issue {
+ createIssuable: createIssue(input: $input) {
+ issuable: issue {
...Issue
}
errors
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index d96d92948be..e044283534a 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -743,11 +743,11 @@ export default {
},
})
.then(({ data }) => {
- if (data.createIssue.errors.length) {
+ if (data.createIssuable.errors.length) {
throw new Error();
}
- const rawIssue = data.createIssue?.issue;
+ const rawIssue = data.createIssuable?.issuable;
const formattedIssue = formatIssue(rawIssue);
dispatch('removeListItem', { listId: list.id, itemId: placeholderId });
dispatch('addListItem', { list, item: formattedIssue, position: 0 });
diff --git a/app/assets/javascripts/branches/components/delete_merged_branches.vue b/app/assets/javascripts/branches/components/delete_merged_branches.vue
index 117c15be907..50fe610d335 100644
--- a/app/assets/javascripts/branches/components/delete_merged_branches.vue
+++ b/app/assets/javascripts/branches/components/delete_merged_branches.vue
@@ -69,7 +69,7 @@ export default {
this.openModal();
},
extraAttrs: {
- 'data-qa-selector': 'delete_merged_branches_button',
+ 'data-testid': 'delete-merged-branches-button',
class: 'gl-text-red-500!',
},
},
@@ -102,12 +102,11 @@ export default {
category="tertiary"
no-caret
placement="right"
- data-qa-selector="delete_merged_branches_dropdown_button"
class="gl-display-none gl-md-display-block!"
:items="dropdownItems"
/>
<gl-button
- data-qa-selector="delete_merged_branches_button"
+ data-testid="delete-merged-branches-button"
category="secondary"
variant="danger"
class="gl-display-block gl-md-display-none!"
@@ -153,7 +152,6 @@ export default {
</gl-sprintf>
<gl-form-input
v-model="enteredText"
- data-qa-selector="delete_merged_branches_input"
type="text"
size="sm"
class="gl-mt-2"
@@ -178,7 +176,6 @@ export default {
ref="deleteMergedBrancesButton"
:disabled="isDeleteButtonDisabled"
variant="danger"
- data-qa-selector="delete_merged_branches_confirmation_button"
data-testid="delete-merged-branches-confirmation-button"
@click="submitForm"
>{{ $options.i18n.deleteButtonText }}</gl-button
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue
index 09b02068388..a25f871ac92 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue
@@ -24,6 +24,10 @@ export default {
type: Array,
required: true,
},
+ hasEnvScopeQuery: {
+ type: Boolean,
+ required: true,
+ },
selectedEnvironmentScope: {
type: String,
required: false,
@@ -32,6 +36,7 @@ export default {
},
data() {
return {
+ isDropdownShown: false,
selectedEnvironment: '',
searchTerm: '',
};
@@ -46,17 +51,20 @@ export default {
return environment.toLowerCase().includes(lowerCasedSearchTerm);
});
},
- isEnvScopeLimited() {
- return this.glFeatures?.ciLimitEnvironmentScope;
+ isDropdownLoading() {
+ return this.areEnvironmentsLoading && this.hasEnvScopeQuery && !this.isDropdownShown;
+ },
+ isDropdownSearching() {
+ return this.areEnvironmentsLoading && this.hasEnvScopeQuery && this.isDropdownShown;
},
searchedEnvironments() {
- // If FF is enabled, search query will be fired so this component will already
- // receive filtered environments during the refetch.
- // If FF is disabled, search the existing list of environments in the frontend
- let filtered = this.isEnvScopeLimited ? this.environments : this.filteredEnvironments;
+ // If hasEnvScopeQuery (applies only to projects for now), search query will be fired so this
+ // component will already receive filtered environments during the refetch.
+ // Otherwise (applies to groups), search the existing list of environments in the frontend
+ let filtered = this.hasEnvScopeQuery ? this.environments : this.filteredEnvironments;
// If there is no search term, make sure to include *
- if (this.isEnvScopeLimited && !this.searchTerm) {
+ if (this.hasEnvScopeQuery && !this.searchTerm) {
filtered = uniq([...filtered, '*']);
}
@@ -65,15 +73,12 @@ export default {
text: environment,
}));
},
- shouldShowSearchLoading() {
- return this.areEnvironmentsLoading && this.isEnvScopeLimited;
- },
shouldRenderCreateButton() {
return this.searchTerm && !this.environments.includes(this.searchTerm);
},
shouldRenderDivider() {
return (
- (this.isEnvScopeLimited || this.shouldRenderCreateButton) && !this.shouldShowSearchLoading
+ (this.hasEnvScopeQuery || this.shouldRenderCreateButton) && !this.areEnvironmentsLoading
);
},
environmentScopeLabel() {
@@ -84,7 +89,7 @@ export default {
debouncedSearch: debounce(function debouncedSearch(searchTerm) {
const newSearchTerm = searchTerm.trim();
this.searchTerm = newSearchTerm;
- if (this.isEnvScopeLimited) {
+ if (this.hasEnvScopeQuery) {
this.$emit('search-environment-scope', newSearchTerm);
}
}, 500),
@@ -96,6 +101,9 @@ export default {
this.$emit('create-environment-scope', this.searchTerm);
this.selectEnvironment(this.searchTerm);
},
+ toggleDropdownShown(isShown) {
+ this.isDropdownShown = isShown;
+ },
},
ENVIRONMENT_QUERY_LIMIT,
i18n: {
@@ -111,14 +119,17 @@ export default {
block
searchable
:items="searchedEnvironments"
- :searching="shouldShowSearchLoading"
+ :loading="isDropdownLoading"
+ :searching="isDropdownSearching"
:toggle-text="environmentScopeLabel"
@search="debouncedSearch"
@select="selectEnvironment"
+ @shown="toggleDropdownShown(true)"
+ @hidden="toggleDropdownShown(false)"
>
<template #footer>
<gl-dropdown-divider v-if="shouldRenderDivider" />
- <div v-if="isEnvScopeLimited" data-testid="max-envs-notice">
+ <div v-if="hasEnvScopeQuery" data-testid="max-envs-notice">
<gl-dropdown-item class="gl-list-style-none" disabled>
<gl-sprintf :message="$options.i18n.maxEnvsNote" class="gl-font-sm">
<template #limit>
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue
index 9c79adffdae..2045b127a82 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue
@@ -3,6 +3,7 @@ import { TYPENAME_GROUP } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, UPDATE_MUTATION_ACTION } from '../constants';
+import getGroupEnvironments from '../graphql/queries/group_environments.query.graphql';
import getGroupVariables from '../graphql/queries/group_variables.query.graphql';
import addGroupVariable from '../graphql/mutations/group_add_variable.mutation.graphql';
import deleteGroupVariable from '../graphql/mutations/group_delete_variable.mutation.graphql';
@@ -22,6 +23,15 @@ export default {
graphqlId() {
return convertToGraphQLId(TYPENAME_GROUP, this.groupId);
},
+ queriesAvailable() {
+ if (this.glFeatures.ciGroupEnvScopeGraphql) {
+ return this.$options.queryData;
+ }
+
+ return {
+ ciVariables: this.$options.queryData.ciVariables,
+ };
+ },
},
mutationData: {
[ADD_MUTATION_ACTION]: addGroupVariable,
@@ -33,6 +43,10 @@ export default {
lookup: (data) => data?.group?.ciVariables,
query: getGroupVariables,
},
+ environments: {
+ lookup: (data) => data?.group?.environmentScopes,
+ query: getGroupEnvironments,
+ },
},
};
</script>
@@ -45,6 +59,6 @@ export default {
entity="group"
:full-path="groupPath"
:mutation-data="$options.mutationData"
- :query-data="$options.queryData"
+ :query-data="queriesAvailable"
/>
</template>
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
index 41514d2d2f1..3af48635f3f 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
@@ -93,6 +93,10 @@ export default {
required: false,
default: false,
},
+ hasEnvScopeQuery: {
+ type: Boolean,
+ required: true,
+ },
mode: {
type: String,
required: true,
@@ -147,7 +151,7 @@ export default {
return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key);
},
environmentsList() {
- if (this.glFeatures?.ciLimitEnvironmentScope) {
+ if (this.hasEnvScopeQuery) {
return this.environments;
}
@@ -385,6 +389,7 @@ export default {
<ci-environments-dropdown
v-if="areScopedVariablesAvailable"
:are-environments-loading="areEnvironmentsLoading"
+ :has-env-scope-query="hasEnvScopeQuery"
:selected-environment-scope="variable.environmentScope"
:environments="environmentsList"
@select-environment="setEnvironmentScope"
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue
index 26e20c690bc..b8a95f9081a 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue
@@ -33,6 +33,10 @@ export default {
required: false,
default: false,
},
+ hasEnvScopeQuery: {
+ type: Boolean,
+ required: true,
+ },
isLoading: {
type: Boolean,
required: false,
@@ -107,6 +111,7 @@ export default {
:are-environments-loading="areEnvironmentsLoading"
:are-scoped-variables-available="areScopedVariablesAvailable"
:environments="environments"
+ :has-env-scope-query="hasEnvScopeQuery"
:hide-environment-scope="hideEnvironmentScope"
:variables="variables"
:mode="mode"
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue
index ee2c0a771cf..9786f25ed87 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue
@@ -159,12 +159,13 @@ export default {
return this.queryData?.environments?.query || {};
},
skip() {
- return !this.queryData?.environments?.query;
+ return !this.hasEnvScopeQuery;
},
variables() {
return {
+ first: ENVIRONMENT_QUERY_LIMIT,
fullPath: this.fullPath,
- ...this.environmentQueryVariables,
+ search: '',
};
},
update(data) {
@@ -179,23 +180,12 @@ export default {
areEnvironmentsLoading() {
return this.$apollo.queries.environments.loading;
},
- environmentQueryVariables() {
- if (this.glFeatures?.ciLimitEnvironmentScope) {
- return {
- first: ENVIRONMENT_QUERY_LIMIT,
- search: '',
- };
- }
-
- return {};
+ hasEnvScopeQuery() {
+ return Boolean(this.queryData?.environments?.query);
},
isLoading() {
- // TODO: Remove areEnvironmentsLoading and show loading icon in dropdown when
- // environment query is loading and FF is enabled
- // https://gitlab.com/gitlab-org/gitlab/-/issues/396990
return (
(this.$apollo.queries.ciVariables.loading && this.isInitialLoading) ||
- this.areEnvironmentsLoading ||
this.isLoadingMoreItems
);
},
@@ -248,9 +238,7 @@ export default {
this.variableMutation(UPDATE_MUTATION_ACTION, variable);
},
async searchEnvironmentScope(searchTerm) {
- if (this.glFeatures?.ciLimitEnvironmentScope) {
- this.$apollo.queries.environments.refetch({ search: searchTerm });
- }
+ this.$apollo.queries.environments.refetch({ search: searchTerm });
},
async variableMutation(mutationAction, variable) {
try {
@@ -296,6 +284,7 @@ export default {
:are-scoped-variables-available="areScopedVariablesAvailable"
:entity="entity"
:environments="environments"
+ :has-env-scope-query="hasEnvScopeQuery"
:hide-environment-scope="hideEnvironmentScope"
:is-loading="isLoading"
:max-variable-limit="maxVariableLimit"
diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_environments.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_environments.query.graphql
new file mode 100644
index 00000000000..5768d370474
--- /dev/null
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_environments.query.graphql
@@ -0,0 +1,10 @@
+query getGroupEnvironments($fullPath: ID!, $first: Int, $search: String) {
+ group(fullPath: $fullPath) {
+ id
+ environmentScopes(first: $first, search: $search) {
+ nodes {
+ name
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue
index 0b57433e894..8d670cb5389 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue
@@ -2,6 +2,7 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
+import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility';
import { pipelineEditorTrackingOptions } from '../../../constants';
export default {
@@ -34,7 +35,7 @@ export default {
this.track(actions.helpDrawerLinks.runners, { label });
},
},
- RUNNER_HELP_URL: 'https://docs.gitlab.com/runner/register/index.html',
+ RUNNER_HELP_URL: `${DOCS_URL}/runner/register/index.html`,
};
</script>
<template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue
index 794763e0cd8..76db9613dc1 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue
@@ -26,7 +26,7 @@ export default {
return [
{
key: 'artifacts.paths',
- title: i18n.ARTIFACTS_AND_CACHE,
+ title: i18n.ARTIFACTS_PATHS,
paths: this.job.artifacts.paths,
generateInputDataTestId: (index) => `artifacts-paths-input-${index}`,
generateDeleteButtonDataTestId: (index) => `delete-artifacts-paths-button-${index}`,
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue
index d0f206e767f..460f508ee74 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue
@@ -54,6 +54,13 @@ export default {
`${this.startInNumber} ${this.startInUnit}${plural}`,
);
},
+ updateWhen(when) {
+ this.$emit('update-job', 'rules[0].when', when);
+
+ if (when === JOB_RULES_WHEN.delayed.value) {
+ this.updateStartIn();
+ }
+ },
},
};
</script>
@@ -73,7 +80,7 @@ export default {
:options="$options.whenOptions"
data-testid="rules-when-select"
:value="job.rules[0].when"
- @input="$emit('update-job', 'rules[0].when', $event)"
+ @input="updateWhen"
/>
</gl-form-group>
<gl-form-group
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 6695c6179cf..0700d9e5439 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue
@@ -16,6 +16,7 @@ import deletePipelineScheduleMutation from '../graphql/mutations/delete_pipeline
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 { ALL_SCOPE } from '../constants';
import PipelineSchedulesTable from './table/pipeline_schedules_table.vue';
import TakeOwnershipModal from './take_ownership_modal.vue';
import DeletePipelineScheduleModal from './delete_pipeline_schedule_modal.vue';
@@ -58,6 +59,9 @@ export default {
pipelinesPath: {
default: '',
},
+ newSchedulePath: {
+ default: '',
+ },
},
apollo: {
schedules: {
@@ -65,7 +69,9 @@ export default {
variables() {
return {
projectPath: this.fullPath,
- status: this.scope,
+ // we need to ensure we send null to the API when
+ // the scope is 'ALL'
+ status: this.scope === ALL_SCOPE ? null : this.scope,
};
},
update(data) {
@@ -111,7 +117,7 @@ export default {
{
text: s__('PipelineSchedules|All'),
count: limitedCounterWithDelimiter(this.count),
- scope: null,
+ scope: ALL_SCOPE,
showBadge: true,
attrs: { 'data-testid': 'pipeline-schedules-all-tab' },
},
@@ -134,7 +140,7 @@ export default {
// this watcher ensures that the count on the all tab
// is not updated when switching to other tabs
schedulesCount(newCount) {
- if (!this.scope) {
+ if (!this.scope || this.scope === ALL_SCOPE) {
this.count = newCount;
}
},
@@ -253,10 +259,10 @@ export default {
</gl-alert>
<gl-tabs
- v-if="isLoading || count > 0"
+ v-if="isLoading || schedulesCount > 0"
sync-active-tab-with-query-params
query-param-name="scope"
- nav-class="gl-flex-grow-1 gl-align-items-center"
+ nav-class="gl-flex-grow-1 gl-align-items-center gl-mt-2"
>
<gl-tab
v-for="tab in tabs"
@@ -289,13 +295,18 @@ export default {
</gl-tab>
<template #tabs-end>
- <gl-button variant="confirm" class="gl-ml-auto" data-testid="new-schedule-button">
+ <gl-button
+ :href="newSchedulePath"
+ 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" />
+ <pipeline-schedule-empty-state v-else-if="!isLoading && schedulesCount === 0" />
<take-ownership-modal
:visible="showTakeOwnershipModal"
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
index 39ac55bb9c5..fbdb60f61f1 100644
--- 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
@@ -1,5 +1,5 @@
<script>
-import scheduleSvg from '@gitlab/svgs/dist/illustrations/schedule-md.svg?raw';
+import SCHEDULE_MD_SVG_URL from '@gitlab/svgs/dist/illustrations/schedule-md.svg?url';
import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
@@ -20,15 +20,18 @@ export default {
],
createNew: s__('PipelineSchedules|Create a new pipeline schedule'),
},
+ SCHEDULE_MD_SVG_URL,
components: {
GlEmptyState,
GlLink,
GlSprintf,
},
- computed: {
- scheduleSvgPath() {
- return `data:image/svg+xml;utf8,${encodeURIComponent(scheduleSvg)}`;
+ inject: {
+ newSchedulePath: {
+ default: '',
},
+ },
+ computed: {
schedulesHelpPath() {
return helpPagePath('ci/pipelines/schedules');
},
@@ -37,9 +40,9 @@ export default {
</script>
<template>
<gl-empty-state
- :svg-path="scheduleSvgPath"
+ :svg-path="$options.SCHEDULE_MD_SVG_URL"
:primary-button-text="$options.i18n.createNew"
- primary-button-link="#"
+ :primary-button-link="newSchedulePath"
>
<template #title>
<h3>
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 367b1812a27..d84a9a4a4b5 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
@@ -8,18 +8,22 @@ import {
GlFormGroup,
GlFormInput,
GlFormTextarea,
- GlLink,
- GlSprintf,
+ GlLoadingIcon,
} from '@gitlab/ui';
-import { uniqueId } from 'lodash';
-import Vue from 'vue';
import { __, s__ } from '~/locale';
+import { createAlert } from '~/alert';
+import { visitUrl, queryToObject } from '~/lib/utils/url_utility';
import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
import RefSelector from '~/ref/components/ref_selector.vue';
import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue';
import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue';
+import createPipelineScheduleMutation from '../graphql/mutations/create_pipeline_schedule.mutation.graphql';
+import updatePipelineScheduleMutation from '../graphql/mutations/update_pipeline_schedule.mutation.graphql';
+import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql';
import { VARIABLE_TYPE, FILE_TYPE } from '../constants';
+const scheduleId = queryToObject(window.location.search).id;
+
export default {
components: {
GlButton,
@@ -30,21 +34,12 @@ export default {
GlFormGroup,
GlFormInput,
GlFormTextarea,
- GlLink,
- GlSprintf,
+ GlLoadingIcon,
RefSelector,
TimezoneDropdown,
IntervalPatternInput,
},
- inject: [
- 'fullPath',
- 'projectId',
- 'defaultBranch',
- 'cron',
- 'cronTimezone',
- 'dailyLimit',
- 'settingsLink',
- ],
+ inject: ['fullPath', 'projectId', 'defaultBranch', 'dailyLimit', 'settingsLink', 'schedulesPath'],
props: {
timezoneData: {
type: Array,
@@ -55,34 +50,79 @@ export default {
required: false,
default: '',
},
+ editing: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ apollo: {
+ schedule: {
+ query: getPipelineSchedulesQuery,
+ variables() {
+ return {
+ projectPath: this.fullPath,
+ ids: scheduleId,
+ };
+ },
+ update(data) {
+ return data.project?.pipelineSchedules?.nodes[0] || {};
+ },
+ result({ data }) {
+ if (data) {
+ const {
+ project: {
+ pipelineSchedules: { nodes },
+ },
+ } = data;
+
+ const schedule = nodes[0];
+ const variables = schedule.variables?.nodes || [];
+
+ this.description = schedule.description;
+ this.cron = schedule.cron;
+ this.cronTimezone = schedule.cronTimezone;
+ this.scheduleRef = schedule.ref;
+ this.variables = variables.map((variable) => {
+ return {
+ id: variable.id,
+ variableType: variable.variableType,
+ key: variable.key,
+ value: variable.value,
+ destroy: false,
+ };
+ });
+ this.addEmptyVariable();
+ this.activated = schedule.active;
+ }
+ },
+ skip() {
+ return !this.editing;
+ },
+ error() {
+ createAlert({ message: this.$options.i18n.scheduleFetchError });
+ },
+ },
},
data() {
return {
- refValue: {
- shortName: this.refParam,
- // this is needed until we add support for ref type in url query strings
- // ensure default branch is called with full ref on load
- // https://gitlab.com/gitlab-org/gitlab/-/issues/287815
- fullName: this.refParam === this.defaultBranch ? `refs/heads/${this.refParam}` : undefined,
- },
+ cron: '',
description: '',
scheduleRef: this.defaultBranch,
activated: true,
- timezone: this.cronTimezone,
- formCiVariables: {},
- // TODO: Add the GraphQL query to help populate the predefined variables
- // app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue#131
- predefinedValueOptions: {},
+ cronTimezone: '',
+ variables: [],
+ schedule: {},
};
},
i18n: {
activated: __('Activated'),
- cronTimezone: s__('PipelineSchedules|Cron timezone'),
+ cronTimezoneText: s__('PipelineSchedules|Cron timezone'),
description: s__('PipelineSchedules|Description'),
shortDescriptionPipeline: s__(
'PipelineSchedules|Provide a short description for this pipeline',
),
- savePipelineSchedule: s__('PipelineSchedules|Save pipeline schedule'),
+ editScheduleBtnText: s__('PipelineSchedules|Edit pipeline schedule'),
+ createScheduleBtnText: s__('PipelineSchedules|Create pipeline schedule'),
cancel: __('Cancel'),
targetBranchTag: __('Select target branch or tag'),
intervalPattern: s__('PipelineSchedules|Interval Pattern'),
@@ -91,6 +131,15 @@ export default {
),
removeVariableLabel: s__('CiVariables|Remove variable'),
variables: s__('Pipeline|Variables'),
+ scheduleCreateError: s__(
+ 'PipelineSchedules|An error occurred while creating the pipeline schedule.',
+ ),
+ scheduleUpdateError: s__(
+ 'PipelineSchedules|An error occurred while updating the pipeline schedule.',
+ ),
+ scheduleFetchError: s__(
+ 'PipelineSchedules|An error occurred while trying to fetch the pipeline schedule.',
+ ),
},
typeOptions: {
[VARIABLE_TYPE]: __('Variable'),
@@ -103,15 +152,6 @@ export default {
dropdownHeader: this.$options.i18n.targetBranchTag,
};
},
- refFullName() {
- return this.refValue.fullName;
- },
- variables() {
- return this.formCiVariables[this.refFullName]?.variables ?? [];
- },
- descriptions() {
- return this.formCiVariables[this.refFullName]?.descriptions ?? {};
- },
typeOptionsListbox() {
return [
{
@@ -127,52 +167,136 @@ export default {
getEnabledRefTypes() {
return [REF_TYPE_BRANCHES, REF_TYPE_TAGS];
},
+ preparedVariablesUpdate() {
+ return this.variables.filter((variable) => variable.key !== '');
+ },
+ preparedVariablesCreate() {
+ return this.preparedVariablesUpdate.map((variable) => {
+ return {
+ key: variable.key,
+ value: variable.value,
+ variableType: variable.variableType,
+ };
+ });
+ },
+ loading() {
+ return this.$apollo.queries.schedule.loading;
+ },
+ buttonText() {
+ return this.editing
+ ? this.$options.i18n.editScheduleBtnText
+ : this.$options.i18n.createScheduleBtnText;
+ },
},
created() {
- Vue.set(this.formCiVariables, this.refFullName, {
- variables: [],
- descriptions: {},
- });
-
- this.addEmptyVariable(this.refFullName);
+ this.addEmptyVariable();
},
methods: {
- addEmptyVariable(refValue) {
- const { variables } = this.formCiVariables[refValue];
+ addEmptyVariable() {
+ const lastVar = this.variables[this.variables.length - 1];
- const lastVar = variables[variables.length - 1];
if (lastVar?.key === '' && lastVar?.value === '') {
return;
}
- variables.push({
- uniqueId: uniqueId(`var-${refValue}`),
- variable_type: VARIABLE_TYPE,
+ this.variables.push({
+ variableType: VARIABLE_TYPE,
key: '',
value: '',
+ destroy: false,
});
},
setVariableAttribute(key, attribute, value) {
- const { variables } = this.formCiVariables[this.refFullName];
- const variable = variables.find((v) => v.key === key);
+ const variable = this.variables.find((v) => v.key === key);
variable[attribute] = value;
},
- shouldShowValuesDropdown(key) {
- return this.predefinedValueOptions[key]?.length > 1;
- },
removeVariable(index) {
- this.variables.splice(index, 1);
+ this.variables[index].destroy = true;
},
canRemove(index) {
return index < this.variables.length - 1;
},
+ async createPipelineSchedule() {
+ try {
+ const {
+ data: {
+ pipelineScheduleCreate: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: createPipelineScheduleMutation,
+ variables: {
+ input: {
+ description: this.description,
+ cron: this.cron,
+ cronTimezone: this.cronTimezone,
+ ref: this.scheduleRef,
+ variables: this.preparedVariablesCreate,
+ active: this.activated,
+ projectPath: this.fullPath,
+ },
+ },
+ });
+
+ if (errors.length > 0) {
+ createAlert({ message: errors[0] });
+ } else {
+ visitUrl(this.schedulesPath);
+ }
+ } catch {
+ createAlert({ message: this.$options.i18n.scheduleCreateError });
+ }
+ },
+ async updatePipelineSchedule() {
+ try {
+ const {
+ data: {
+ pipelineScheduleUpdate: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: updatePipelineScheduleMutation,
+ variables: {
+ input: {
+ id: this.schedule.id,
+ description: this.description,
+ cron: this.cron,
+ cronTimezone: this.cronTimezone,
+ ref: this.scheduleRef,
+ variables: this.preparedVariablesUpdate,
+ active: this.activated,
+ },
+ },
+ });
+
+ if (errors.length > 0) {
+ createAlert({ message: errors[0] });
+ } else {
+ visitUrl(this.schedulesPath);
+ }
+ } catch {
+ createAlert({ message: this.$options.i18n.scheduleUpdateError });
+ }
+ },
+ scheduleHandler() {
+ if (this.editing) {
+ this.updatePipelineSchedule();
+ } else {
+ this.createPipelineSchedule();
+ }
+ },
+ setCronValue(cron) {
+ this.cron = cron;
+ },
+ setTimezone(timezone) {
+ this.cronTimezone = timezone.identifier || '';
+ },
},
};
</script>
<template>
- <div class="col-lg-8">
- <gl-form>
+ <div class="col-lg-8 gl-pl-0">
+ <gl-loading-icon v-if="loading && editing" size="lg" />
+ <gl-form v-else>
<!--Description-->
<gl-form-group :label="$options.i18n.description" label-for="schedule-description">
<gl-form-input
@@ -181,6 +305,7 @@ export default {
type="text"
:placeholder="$options.i18n.shortDescriptionPipeline"
data-testid="schedule-description"
+ required
/>
</gl-form-group>
<!--Interval Pattern-->
@@ -190,21 +315,24 @@ export default {
:initial-cron-interval="cron"
:daily-limit="dailyLimit"
:send-native-errors="false"
+ @cronValue="setCronValue"
/>
</gl-form-group>
<!--Timezone-->
- <gl-form-group :label="$options.i18n.cronTimezone" label-for="schedule-timezone">
+ <gl-form-group :label="$options.i18n.cronTimezoneText" label-for="schedule-timezone">
<timezone-dropdown
id="schedule-timezone"
- :value="timezone"
+ :value="cronTimezone"
:timezone-data="timezoneData"
name="schedule-timezone"
+ @input="setTimezone"
/>
</gl-form-group>
<!--Branch/Tag Selector-->
<gl-form-group :label="$options.i18n.targetBranchTag" label-for="schedule-target-branch-tag">
<ref-selector
id="schedule-target-branch-tag"
+ v-model="scheduleRef"
:enabled-ref-types="getEnabledRefTypes"
:project-id="projectId"
:value="scheduleRef"
@@ -217,23 +345,23 @@ export default {
<gl-form-group :label="$options.i18n.variables">
<div
v-for="(variable, index) in variables"
- :key="variable.uniqueId"
- class="gl-mb-3 gl-pb-2"
- data-testid="ci-variable-row"
+ :key="`var-${index}`"
data-qa-selector="ci_variable_row_container"
>
<div
- class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row"
+ v-if="!variable.destroy"
+ class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row gl-mb-3 gl-pb-2"
+ data-testid="ci-variable-row"
>
<gl-dropdown
- :text="$options.typeOptions[variable.variable_type]"
+ :text="$options.typeOptions[variable.variableType]"
:class="$options.formElementClasses"
data-testid="pipeline-form-ci-variable-type"
>
<gl-dropdown-item
v-for="type in Object.keys($options.typeOptions)"
:key="type"
- @click="setVariableAttribute(variable.key, 'variable_type', type)"
+ @click="setVariableAttribute(variable.key, 'variableType', type)"
>
{{ $options.typeOptions[type] }}
</gl-dropdown-item>
@@ -244,26 +372,10 @@ export default {
:class="$options.formElementClasses"
data-testid="pipeline-form-ci-variable-key"
data-qa-selector="ci_variable_key_field"
- @change="addEmptyVariable(refFullName)"
+ @change="addEmptyVariable()"
/>
- <gl-dropdown
- v-if="shouldShowValuesDropdown(variable.key)"
- :text="variable.value"
- :class="$options.formElementClasses"
- class="gl-flex-grow-1 gl-mr-0!"
- data-testid="pipeline-form-ci-variable-value-dropdown"
- >
- <gl-dropdown-item
- v-for="value in predefinedValueOptions[variable.key]"
- :key="value"
- data-testid="pipeline-form-ci-variable-value-dropdown-items"
- @click="setVariableAttribute(variable.key, 'value', value)"
- >
- {{ value }}
- </gl-dropdown-item>
- </gl-dropdown>
+
<gl-form-textarea
- v-else
v-model="variable.value"
:placeholder="s__('CiVariables|Input variable value')"
class="gl-mb-3 gl-h-7!"
@@ -292,30 +404,19 @@ export default {
/>
</template>
</div>
- <div v-if="descriptions[variable.key]" class="gl-text-gray-500 gl-mb-3">
- {{ descriptions[variable.key] }}
- </div>
</div>
-
- <template #description
- ><gl-sprintf :message="$options.i18n.variablesDescription">
- <template #link="{ content }">
- <gl-link :href="settingsLink">{{ content }}</gl-link>
- </template>
- </gl-sprintf></template
- >
</gl-form-group>
<!--Activated-->
- <gl-form-checkbox id="schedule-active" v-model="activated" class="gl-mb-3">{{
- $options.i18n.activated
- }}</gl-form-checkbox>
+ <gl-form-checkbox id="schedule-active" v-model="activated" class="gl-mb-3">
+ {{ $options.i18n.activated }}
+ </gl-form-checkbox>
- <gl-button type="submit" variant="confirm" data-testid="schedule-submit-button">{{
- $options.i18n.savePipelineSchedule
- }}</gl-button>
- <gl-button type="reset" data-testid="schedule-cancel-button">{{
- $options.i18n.cancel
- }}</gl-button>
+ <gl-button variant="confirm" data-testid="schedule-submit-button" @click="scheduleHandler">
+ {{ buttonText }}
+ </gl-button>
+ <gl-button :href="schedulesPath" data-testid="schedule-cancel-button">
+ {{ $options.i18n.cancel }}
+ </gl-button>
</gl-form>
</div>
</template>
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 5bd58bfd95d..a56da06f5da 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
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlButtonGroup, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { s__ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
export const i18n = {
playTooltip: s__('PipelineSchedules|Run pipeline schedule'),
@@ -44,6 +45,11 @@ export default {
canRemove() {
return this.schedule.userPermissions.adminPipelineSchedule;
},
+ editPathWithIdParam() {
+ const id = getIdFromGraphQLId(this.schedule.id);
+
+ return `${this.schedule.editPath}?id=${id}`;
+ },
},
};
</script>
@@ -67,7 +73,14 @@ export default {
data-testid="take-ownership-pipeline-schedule-btn"
@click="$emit('showTakeOwnershipModal', schedule.id)"
/>
- <gl-button v-if="canUpdate" v-gl-tooltip :title="$options.i18n.editTooltip" icon="pencil" />
+ <gl-button
+ v-if="canUpdate"
+ v-gl-tooltip
+ :href="editPathWithIdParam"
+ :title="$options.i18n.editTooltip"
+ icon="pencil"
+ data-testid="edit-pipeline-schedule-btn"
+ />
<gl-button
v-if="canRemove"
v-gl-tooltip
diff --git a/app/assets/javascripts/ci/pipeline_schedules/constants.js b/app/assets/javascripts/ci/pipeline_schedules/constants.js
index b4ab1143f60..16dab33ce29 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/constants.js
+++ b/app/assets/javascripts/ci/pipeline_schedules/constants.js
@@ -1,2 +1,3 @@
-export const VARIABLE_TYPE = 'env_var';
-export const FILE_TYPE = 'file';
+export const VARIABLE_TYPE = 'ENV_VAR';
+export const FILE_TYPE = 'FILE';
+export const ALL_SCOPE = 'ALL';
diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/create_pipeline_schedule.mutation.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/create_pipeline_schedule.mutation.graphql
new file mode 100644
index 00000000000..0bea1bb8360
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/create_pipeline_schedule.mutation.graphql
@@ -0,0 +1,6 @@
+mutation createPipelineSchedule($input: PipelineScheduleCreateInput!) {
+ pipelineScheduleCreate(input: $input) {
+ clientMutationId
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/update_pipeline_schedule.mutation.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/update_pipeline_schedule.mutation.graphql
new file mode 100644
index 00000000000..a6a937af74a
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/update_pipeline_schedule.mutation.graphql
@@ -0,0 +1,6 @@
+mutation updatePipelineSchedule($input: PipelineScheduleUpdateInput!) {
+ pipelineScheduleUpdate(input: $input) {
+ clientMutationId
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql
index 6167c7dc577..29a26be0344 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql
+++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql
@@ -1,16 +1,24 @@
-query getPipelineSchedulesQuery($projectPath: ID!, $status: PipelineScheduleStatus) {
+query getPipelineSchedulesQuery(
+ $projectPath: ID!
+ $status: PipelineScheduleStatus
+ $ids: [ID!] = null
+) {
currentUser {
id
username
}
project(fullPath: $projectPath) {
id
- pipelineSchedules(status: $status) {
+ pipelineSchedules(status: $status, ids: $ids) {
count
nodes {
id
description
+ cron
+ cronTimezone
+ ref
forTag
+ editPath
refPath
refForDisplay
lastPipeline {
@@ -34,6 +42,14 @@ query getPipelineSchedulesQuery($projectPath: ID!, $status: PipelineScheduleStat
name
webPath
}
+ variables {
+ nodes {
+ id
+ variableType
+ key
+ value
+ }
+ }
userPermissions {
playPipelineSchedule
updatePipelineSchedule
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 8bca4f85e9f..71db9400909 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, pipelinesPath } = containerEl.dataset;
+ const { fullPath, pipelinesPath, newSchedulePath, schedulesPath } = containerEl.dataset;
return new Vue({
el: containerEl,
@@ -27,6 +27,8 @@ export default () => {
provide: {
fullPath,
pipelinesPath,
+ newSchedulePath,
+ schedulesPath,
},
render(createElement) {
return createElement(PipelineSchedules);
diff --git a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js
index 445161f99cb..6bf121d39b6 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js
+++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js
@@ -9,7 +9,7 @@ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
-export default (selector) => {
+export default (selector, editing = false) => {
const containerEl = document.querySelector(selector);
if (!containerEl) {
@@ -18,13 +18,12 @@ export default (selector) => {
const {
fullPath,
- cron,
dailyLimit,
timezoneData,
- cronTimezone,
projectId,
defaultBranch,
settingsLink,
+ schedulesPath,
} = containerEl.dataset;
return new Vue({
@@ -36,15 +35,15 @@ export default (selector) => {
projectId,
defaultBranch,
dailyLimit: dailyLimit ?? '',
- cronTimezone: cronTimezone ?? '',
- cron: cron ?? '',
settingsLink,
+ schedulesPath,
},
render(createElement) {
return createElement(PipelineSchedulesForm, {
props: {
timezoneData: JSON.parse(timezoneData),
refParam: defaultBranch,
+ editing,
},
});
},
diff --git a/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue b/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue
deleted file mode 100644
index b21a486e259..00000000000
--- a/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue
+++ /dev/null
@@ -1,106 +0,0 @@
-<script>
-import { s__ } from '~/locale';
-import ReportItem from '~/ci/reports/components/report_item.vue';
-import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
-
-export default {
- components: {
- ReportItem,
- SmartVirtualList,
- },
- props: {
- component: {
- type: String,
- required: false,
- default: '',
- },
- nestedLevel: {
- type: Number,
- required: false,
- default: 0,
- validator: (value) => [0, 1, 2].includes(value),
- },
- resolvedIssues: {
- type: Array,
- required: false,
- default: () => [],
- },
- unresolvedIssues: {
- type: Array,
- required: false,
- default: () => [],
- },
- resolvedHeading: {
- type: String,
- required: false,
- default: s__('ciReport|Fixed'),
- },
- unresolvedHeading: {
- type: String,
- required: false,
- default: s__('ciReport|New'),
- },
- },
- groups: ['unresolved', 'resolved'],
- typicalReportItemHeight: 32,
- maxShownReportItems: 20,
- computed: {
- groups() {
- return this.$options.groups
- .map((group) => ({
- name: group,
- issues: this[`${group}Issues`],
- heading: this[`${group}Heading`],
- }))
- .filter(({ issues }) => issues.length > 0);
- },
- listLength() {
- // every group has a header which is rendered as a list item
- const groupsCount = this.groups.length;
- const issuesCount = this.groups.reduce(
- (totalIssues, { issues }) => totalIssues + issues.length,
- 0,
- );
-
- return groupsCount + issuesCount;
- },
- listClasses() {
- return {
- 'gl-pl-9': this.nestedLevel === 1,
- 'gl-pl-11-5': this.nestedLevel === 2,
- };
- },
- },
-};
-</script>
-
-<template>
- <smart-virtual-list
- :length="listLength"
- :remain="$options.maxShownReportItems"
- :size="$options.typicalReportItemHeight"
- :class="listClasses"
- class="report-block-container"
- wtag="ul"
- wclass="report-block-list"
- >
- <template v-for="(group, groupIndex) in groups">
- <h2
- :key="group.name"
- :data-testid="`${group.name}Heading`"
- :class="[groupIndex > 0 ? 'mt-2' : 'mt-0']"
- class="h5 mb-1"
- >
- {{ group.heading }}
- </h2>
- <report-item
- v-for="(issue, issueIndex) in group.issues"
- :key="`${group.name}-${issue.name}-${group.name}-${issueIndex}`"
- :issue="issue"
- :show-report-section-status-icon="false"
- :component="component"
- status="none"
- />
- </template>
- </smart-virtual-list>
-</template>
diff --git a/app/assets/javascripts/ci/reports/components/summary_row.vue b/app/assets/javascripts/ci/reports/components/summary_row.vue
deleted file mode 100644
index ee55368c829..00000000000
--- a/app/assets/javascripts/ci/reports/components/summary_row.vue
+++ /dev/null
@@ -1,93 +0,0 @@
-<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import HelpPopover from '~/vue_shared/components/help_popover.vue';
-import { ICON_WARNING } from '../constants';
-
-/**
- * Renders the summary row for each report
- *
- * Used both in MR widget and Pipeline's view for:
- * - Unit tests reports
- * - Security reports
- */
-
-export default {
- name: 'ReportSummaryRow',
- components: {
- CiIcon,
- HelpPopover,
- GlLoadingIcon,
- },
- props: {
- nestedSummary: {
- type: Boolean,
- required: false,
- default: false,
- },
- summary: {
- type: String,
- required: false,
- default: '',
- },
- statusIcon: {
- type: String,
- required: true,
- },
- popoverOptions: {
- type: Object,
- required: false,
- default: null,
- },
- },
- computed: {
- iconStatus() {
- return {
- group: this.statusIcon,
- icon: `status_${this.statusIcon}`,
- };
- },
- rowClasses() {
- if (!this.nestedSummary) {
- return ['gl-px-5'];
- }
- return ['gl-pl-9', 'gl-pr-5', { 'gl-bg-gray-10': this.statusIcon === ICON_WARNING }];
- },
- statusIconSize() {
- if (!this.nestedSummary) {
- return 24;
- }
- return 16;
- },
- },
-};
-</script>
-<template>
- <div
- class="gl-border-t-solid gl-border-t-gray-100 gl-border-t-1 gl-py-3 gl-display-flex gl-align-items-center"
- :class="rowClasses"
- >
- <div class="gl-mr-3">
- <gl-loading-icon
- v-if="statusIcon === 'loading'"
- css-class="report-block-list-loading-icon"
- size="lg"
- />
- <ci-icon v-else :status="iconStatus" :size="statusIconSize" data-testid="summary-row-icon" />
- </div>
- <div class="report-block-list-issue-description">
- <div class="report-block-list-issue-description-text" data-testid="summary-row-description">
- <slot name="summary">{{ summary }}</slot
- ><span v-if="popoverOptions" class="text-nowrap"
- >&nbsp;<help-popover v-if="popoverOptions" :options="popoverOptions" class="align-top" />
- </span>
- </div>
- </div>
- <div
- v-if="$slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */"
- class="text-right flex-fill d-flex justify-content-end flex-column flex-sm-row"
- >
- <slot></slot>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/ci/reports/constants.js b/app/assets/javascripts/ci/reports/constants.js
index 1137236d355..3968f8db752 100644
--- a/app/assets/javascripts/ci/reports/constants.js
+++ b/app/assets/javascripts/ci/reports/constants.js
@@ -7,8 +7,6 @@ export const STATUS_SUCCESS = 'success';
export const STATUS_NEUTRAL = 'neutral';
export const STATUS_NOT_FOUND = 'not_found';
-export const ICON_WARNING = 'warning';
-
export const status = {
LOADING,
ERROR,
@@ -22,3 +20,6 @@ export const status = {
export const SLOT_SUCCESS = 'success';
export const SLOT_LOADING = 'loading';
export const SLOT_ERROR = 'error';
+
+export const CODE_QUALITY_SCALE_KEY = 'codeQuality';
+export const SAST_SCALE_KEY = 'sast';
diff --git a/app/assets/javascripts/ci/reports/sast/constants.js b/app/assets/javascripts/ci/reports/sast/constants.js
new file mode 100644
index 00000000000..3800065917b
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/sast/constants.js
@@ -0,0 +1,44 @@
+export const SEVERITY_CLASSES = {
+ info: 'gl-text-blue-400',
+ low: 'gl-text-orange-300',
+ medium: 'gl-text-orange-400',
+ high: 'gl-text-red-600',
+ critical: 'gl-text-red-800',
+ unknown: 'gl-text-gray-400',
+};
+
+export const SEVERITY_ICONS = {
+ info: 'severity-info',
+ low: 'severity-low',
+ medium: 'severity-medium',
+ high: 'severity-high',
+ critical: 'severity-critical',
+ unknown: 'severity-unknown',
+};
+
+export const SEVERITIES = {
+ info: {
+ class: SEVERITY_CLASSES.info,
+ name: SEVERITY_ICONS.info,
+ },
+ low: {
+ class: SEVERITY_CLASSES.low,
+ name: SEVERITY_ICONS.low,
+ },
+ medium: {
+ class: SEVERITY_CLASSES.medium,
+ name: SEVERITY_ICONS.medium,
+ },
+ high: {
+ class: SEVERITY_CLASSES.high,
+ name: SEVERITY_ICONS.high,
+ },
+ critical: {
+ class: SEVERITY_CLASSES.critical,
+ name: SEVERITY_ICONS.critical,
+ },
+ unknown: {
+ class: SEVERITY_CLASSES.unknown,
+ name: SEVERITY_ICONS.unknown,
+ },
+};
diff --git a/app/assets/javascripts/ci/reports/utils.js b/app/assets/javascripts/ci/reports/utils.js
new file mode 100644
index 00000000000..bb6eddf2cce
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/utils.js
@@ -0,0 +1,20 @@
+import { SEVERITIES as SEVERITIES_CODE_QUALITY } from '~/ci/reports/codequality_report/constants';
+import { SEVERITIES as SEVERITIES_SAST } from '~/ci/reports/sast/constants';
+import { SAST_SCALE_KEY } from './constants';
+
+function mapSeverity(findings) {
+ const severityInfo =
+ findings.scale === SAST_SCALE_KEY ? SEVERITIES_SAST : SEVERITIES_CODE_QUALITY;
+ return {
+ ...findings,
+ class: severityInfo[findings.severity].class,
+ name: severityInfo[findings.severity].name,
+ };
+}
+
+export function getSeverity(findings) {
+ if (Array.isArray(findings)) {
+ return findings.map((finding) => mapSeverity(finding));
+ }
+ return mapSeverity(findings);
+}
diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
index d385d32fd9d..c2ec8462a0e 100644
--- a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
@@ -4,10 +4,8 @@ import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { visitUrl } from '~/lib/utils/url_utility';
-import RunnerDeleteButton from '../components/runner_delete_button.vue';
-import RunnerEditButton from '../components/runner_edit_button.vue';
-import RunnerPauseButton from '../components/runner_pause_button.vue';
import RunnerHeader from '../components/runner_header.vue';
+import RunnerHeaderActions from '../components/runner_header_actions.vue';
import RunnerDetailsTabs from '../components/runner_details_tabs.vue';
import { I18N_FETCH_ERROR } from '../constants';
@@ -18,10 +16,8 @@ import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_lo
export default {
name: 'AdminRunnerShowApp',
components: {
- RunnerDeleteButton,
- RunnerEditButton,
- RunnerPauseButton,
RunnerHeader,
+ RunnerHeaderActions,
RunnerDetailsTabs,
},
props: {
@@ -80,9 +76,11 @@ export default {
<div>
<runner-header v-if="runner" :runner="runner">
<template #actions>
- <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
- <runner-pause-button v-if="canUpdate" :runner="runner" />
- <runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" />
+ <runner-header-actions
+ :runner="runner"
+ :edit-path="runner.editAdminUrl"
+ @deleted="onDeleted"
+ />
</template>
</runner-header>
<runner-details-tabs v-if="runner" :runner="runner" />
diff --git a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
index 4d88feebe53..2168685e703 100644
--- a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
@@ -126,10 +126,6 @@ export default {
isSearchFiltered() {
return isSearchFiltered(this.search);
},
- shouldShowCreateRunnerWorkflow() {
- // create_runner_workflow_for_admin feature flag
- return this.glFeatures.createRunnerWorkflowForAdmin;
- },
},
watch: {
search: {
@@ -193,14 +189,14 @@ export default {
/>
<div class="gl-w-full gl-md-w-auto gl-display-flex">
- <gl-button v-if="shouldShowCreateRunnerWorkflow" :href="newRunnerPath" variant="confirm">
+ <gl-button :href="newRunnerPath" variant="confirm">
{{ s__('Runners|New instance runner') }}
</gl-button>
<registration-dropdown
class="gl-ml-3"
:registration-token="registrationToken"
:type="$options.INSTANCE_TYPE"
- right
+ placement="right"
/>
</div>
</div>
diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue
index 9f4ce14f704..cc31afea88c 100644
--- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
-import { sprintf, __ } from '~/locale';
+import { sprintf, __, formatNumber } from '~/locale';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
@@ -49,6 +49,12 @@ export default {
managersCount() {
return this.runner.managers?.count || 0;
},
+ firstIpAddress() {
+ return this.runner.managers?.nodes?.[0]?.ipAddress || null;
+ },
+ additionalIpAddressCount() {
+ return this.managersCount - 1;
+ },
jobCount() {
return formatJobCount(this.runner.jobCount);
},
@@ -63,6 +69,9 @@ export default {
return null;
},
},
+ methods: {
+ formatNumber,
+ },
i18n: {
I18N_NO_DESCRIPTION,
I18N_LOCKED_RUNNER_DESCRIPTION,
@@ -120,8 +129,11 @@ export default {
</gl-sprintf>
</runner-summary-field>
- <runner-summary-field v-if="runner.ipAddress" icon="disk" :tooltip="__('IP Address')">
- {{ runner.ipAddress }}
+ <runner-summary-field v-if="firstIpAddress" icon="disk" :tooltip="__('IP Address')">
+ {{ firstIpAddress }}
+ <template v-if="additionalIpAddressCount"
+ >(+{{ formatNumber(additionalIpAddressCount) }})</template
+ >
</runner-summary-field>
<runner-summary-field icon="pipeline" data-testid="job-count" :tooltip="__('Jobs')">
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue
index 2fdf8456615..0154cd2a3ec 100644
--- a/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue
@@ -1,5 +1,11 @@
<script>
-import { GlDropdown, GlDropdownForm, GlDropdownItem, GlDropdownDivider, GlIcon } from '@gitlab/ui';
+import {
+ GlDisclosureDropdown,
+ GlDropdownForm,
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdownGroup,
+ GlIcon,
+} from '@gitlab/ui';
import { s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
@@ -20,12 +26,15 @@ export default {
showInstallationInstructions: s__(
'Runners|Show runner installation and registration instructions',
),
+ supportForRegistrationTokensDeprecated: s__(
+ 'Runners|Support for registration tokens is deprecated',
+ ),
},
components: {
- GlDropdown,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdownGroup,
GlDropdownForm,
- GlDropdownItem,
- GlDropdownDivider,
GlIcon,
RegistrationToken,
RunnerInstructionsModal,
@@ -51,14 +60,6 @@ export default {
};
},
computed: {
- isDeprecated() {
- // Show a compact version when used as secondary option
- // create_runner_workflow_for_admin or create_runner_workflow_for_namespace
- return (
- this.glFeatures?.createRunnerWorkflowForAdmin ||
- this.glFeatures?.createRunnerWorkflowForNamespace
- );
- },
actionText() {
switch (this.type) {
case INSTANCE_TYPE:
@@ -71,30 +72,6 @@ export default {
return I18N_REGISTER_RUNNER;
}
},
- dropdownText() {
- if (this.isDeprecated) {
- return '';
- }
- return this.actionText;
- },
- dropdownToggleClass() {
- if (this.isDeprecated) {
- return ['gl-px-3!'];
- }
- return [];
- },
- dropdownCategory() {
- if (this.isDeprecated) {
- return 'tertiary';
- }
- return 'primary';
- },
- dropdownVariant() {
- if (this.isDeprecated) {
- return 'default';
- }
- return 'confirm';
- },
},
methods: {
onShowInstructionsClick() {
@@ -103,46 +80,51 @@ export default {
onTokenReset(token) {
this.currentRegistrationToken = token;
- this.$refs.runnerRegistrationDropdown.hide(true);
+ this.$refs.runnerRegistrationDropdown.close();
+ },
+ onCopy() {
+ this.$refs.runnerRegistrationDropdown.close();
},
},
};
</script>
<template>
- <gl-dropdown
+ <gl-disclosure-dropdown
ref="runnerRegistrationDropdown"
- menu-class="gl-w-auto!"
- :text="dropdownText"
- :toggle-class="dropdownToggleClass"
- :variant="dropdownVariant"
- :category="dropdownCategory"
+ :toggle-text="actionText"
+ toggle-class="gl-px-3!"
+ variant="default"
+ category="tertiary"
v-bind="$attrs"
+ icon="ellipsis_v"
+ text-sr-only
+ no-caret
>
- <template v-if="isDeprecated" #button-content>
- <span class="gl-sr-only">{{ actionText }}</span>
- <gl-icon name="ellipsis_v" />
- </template>
<gl-dropdown-form class="gl-p-4!">
- <registration-token input-id="token-value" :value="currentRegistrationToken">
- <template v-if="isDeprecated" #label-description>
+ <registration-token input-id="token-value" :value="currentRegistrationToken" @copy="onCopy">
+ <template #label-description>
<gl-icon name="warning" class="gl-text-orange-500" />
<span class="gl-text-secondary">
- {{ s__('Runners|Support for registration tokens is deprecated') }}
+ {{ $options.i18n.supportForRegistrationTokensDeprecated }}
</span>
</template>
</registration-token>
</gl-dropdown-form>
- <gl-dropdown-divider />
- <gl-dropdown-item @click.capture.native.stop="onShowInstructionsClick">
- {{ $options.i18n.showInstallationInstructions }}
- <runner-instructions-modal
- ref="runnerInstructionsModal"
- :registration-token="currentRegistrationToken"
- data-testid="runner-instructions-modal"
- />
- </gl-dropdown-item>
- <gl-dropdown-divider />
- <registration-token-reset-dropdown-item :type="type" @tokenReset="onTokenReset" />
- </gl-dropdown>
+ <gl-disclosure-dropdown-group bordered>
+ <gl-disclosure-dropdown-item @action="onShowInstructionsClick">
+ <template #list-item>
+ {{ $options.i18n.showInstallationInstructions }}
+ <runner-instructions-modal
+ ref="runnerInstructionsModal"
+ :registration-token="currentRegistrationToken"
+ data-testid="runner-instructions-modal"
+ />
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown-group>
+ <gl-disclosure-dropdown-group bordered>
+ <registration-token-reset-dropdown-item :type="type" @tokenReset="onTokenReset" />
+ </gl-disclosure-dropdown-group>
+ </gl-disclosure-dropdown>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue
index b196bccf66f..339c92a427f 100644
--- a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue
@@ -31,6 +31,7 @@ export default {
onCopy() {
// value already in the clipboard, simply notify the user
this.$toast?.show(s__('Runners|Registration token copied!'));
+ this.$emit('copy');
},
},
I18N_COPY_BUTTON_TITLE: s__('Runners|Copy registration token'),
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue
index 6ce88fc54de..47ca3ed6227 100644
--- a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
+import { GlDisclosureDropdownItem, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
import { createAlert } from '~/alert';
import { TYPENAME_GROUP, TYPENAME_PROJECT } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
@@ -19,7 +19,7 @@ export default {
name: 'RunnerRegistrationTokenReset',
i18n,
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
GlLoadingIcon,
GlModal,
},
@@ -124,18 +124,20 @@ export default {
};
</script>
<template>
- <gl-dropdown-item v-gl-modal="$options.modalId">
- {{ __('Reset registration token') }}
- <gl-modal
- size="sm"
- :modal-id="$options.modalId"
- :action-primary="actionPrimary"
- :action-secondary="actionSecondary"
- :title="$options.i18n.modalTitle"
- @primary="handleModalPrimary"
- >
- <p>{{ $options.i18n.modalCopy }}</p>
- </gl-modal>
- <gl-loading-icon v-if="loading" inline />
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item v-gl-modal="$options.modalId">
+ <template #list-item>
+ {{ __('Reset registration token') }}
+ <gl-modal
+ size="sm"
+ :modal-id="$options.modalId"
+ :action-primary="actionPrimary"
+ :action-secondary="actionSecondary"
+ :title="$options.i18n.modalTitle"
+ @primary="handleModalPrimary"
+ >
+ <p>{{ $options.i18n.modalCopy }}</p>
+ </gl-modal>
+ <gl-loading-icon v-if="loading" inline />
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_action.vue b/app/assets/javascripts/ci/runner/components/runner_delete_action.vue
new file mode 100644
index 00000000000..db8133c1ccb
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_delete_action.vue
@@ -0,0 +1,126 @@
+<script>
+import runnerDeleteMutation from '~/ci/runner/graphql/shared/runner_delete.mutation.graphql';
+import { createAlert } from '~/alert';
+import { sprintf, s__ } from '~/locale';
+import { captureException } from '~/ci/runner/sentry_utils';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { I18N_DELETED_TOAST } from '../constants';
+import RunnerDeleteModal from './runner_delete_modal.vue';
+
+/**
+ * Component that wraps a delete GraphQL mutation for the
+ * runner, given its id.
+ *
+ * You can use the slot to define a presentation for the
+ * delete action, like a button or dropdown item.
+ *
+ * Usage:
+ *
+ * ```vue
+ * <runner-delete-action
+ * #default="{ loading, onClick }"
+ * :runner="runner"
+ * @done="onDeleted"
+ * >
+ * <button :disabled="loading" @click="onClick"> Delete! </button>
+ * </runner-pause-action>
+ * ```
+ *
+ */
+export default {
+ name: 'RunnerDeleteAction',
+ components: {
+ RunnerDeleteModal,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ validator: (runner) => {
+ return runner?.id && runner?.shortSha;
+ },
+ },
+ },
+ emits: ['done'],
+ data() {
+ return {
+ loading: false,
+ };
+ },
+ computed: {
+ runnerId() {
+ return getIdFromGraphQLId(this.runner.id);
+ },
+ runnerName() {
+ return `#${this.runnerId} (${this.runner.shortSha})`;
+ },
+ runnerManagersCount() {
+ return this.runner.managers?.count || 0;
+ },
+ runnerDeleteModalId() {
+ return `delete-runner-modal-${this.runnerId}`;
+ },
+ },
+ methods: {
+ onClick() {
+ this.$refs.modal.show();
+ },
+ async onDelete() {
+ // "loading" stays "true" until this row is removed,
+ // should only change back if the operation fails.
+ this.loading = true;
+ try {
+ await this.$apollo.mutate({
+ mutation: runnerDeleteMutation,
+ variables: {
+ input: {
+ id: this.runner.id,
+ },
+ },
+ update: (cache, { data }) => {
+ const { errors } = data.runnerDelete;
+
+ if (errors?.length) {
+ this.onError(new Error(errors.join(' ')));
+ return;
+ }
+
+ this.$emit('done', {
+ message: sprintf(I18N_DELETED_TOAST, { name: this.runnerName }),
+ });
+
+ // Remove deleted runner from the cache
+ const cacheId = cache.identify(this.runner);
+ cache.evict({ id: cacheId });
+ cache.gc();
+ },
+ });
+ } catch (e) {
+ this.onError(e);
+ }
+ },
+ onError(error) {
+ this.loading = false;
+ const { message } = error;
+ const title = sprintf(s__('Runners|Runner %{runnerName} failed to delete'), {
+ runnerName: this.runnerName,
+ });
+
+ createAlert({ title, message });
+ captureException({ error, component: this.$options.name });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <slot :loading="loading" :on-click="onClick"></slot>
+ <runner-delete-modal
+ ref="modal"
+ :modal-id="runnerDeleteModalId"
+ :runner-name="runnerName"
+ :managers-count="runnerManagersCount"
+ @primary="onDelete"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
index 3560521e8d7..d228a022032 100644
--- a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
@@ -1,30 +1,21 @@
<script>
-import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
-import runnerDeleteMutation from '~/ci/runner/graphql/shared/runner_delete.mutation.graphql';
-import { createAlert } from '~/alert';
-import { sprintf, s__ } from '~/locale';
-import { captureException } from '~/ci/runner/sentry_utils';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { I18N_DELETE_RUNNER, I18N_DELETED_TOAST } from '../constants';
-import RunnerDeleteModal from './runner_delete_modal.vue';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { I18N_DELETE_RUNNER } from '../constants';
+import RunnerDeleteAction from './runner_delete_action.vue';
export default {
name: 'RunnerDeleteButton',
components: {
GlButton,
- RunnerDeleteModal,
+ RunnerDeleteAction,
},
directives: {
GlTooltip: GlTooltipDirective,
- GlModal: GlModalDirective,
},
props: {
runner: {
type: Object,
required: true,
- validator: (runner) => {
- return runner?.id && runner?.shortSha;
- },
},
compact: {
type: Boolean,
@@ -39,17 +30,11 @@ export default {
};
},
computed: {
- runnerId() {
- return getIdFromGraphQLId(this.runner.id);
- },
- runnerName() {
- return `#${this.runnerId} (${this.runner.shortSha})`;
- },
- runnerManagersCount() {
- return this.runner.managers?.count || 0;
- },
- runnerDeleteModalId() {
- return `delete-runner-modal-${this.runnerId}`;
+ buttonContent() {
+ if (this.compact) {
+ return null;
+ }
+ return I18N_DELETE_RUNNER;
},
icon() {
if (this.compact) {
@@ -57,12 +42,6 @@ export default {
}
return '';
},
- buttonContent() {
- if (this.compact) {
- return null;
- }
- return I18N_DELETE_RUNNER;
- },
buttonClass() {
// Ensure a square button is shown when compact: true.
// Without this class we will have distorted/rectangular button.
@@ -78,83 +57,36 @@ export default {
return null;
},
tooltip() {
- // Only show basic "delete" tooltip when compact.
- // Also prevent a "sticky" tooltip: If this button is
- // loading, mouseout listeners don't run leaving the tooltip stuck
- if (this.compact && !this.deleting) {
+ if (this.compact) {
return I18N_DELETE_RUNNER;
}
return '';
},
},
methods: {
- async onDelete() {
- // Deleting stays "true" until this row is removed,
- // should only change back if the operation fails.
- this.deleting = true;
- try {
- await this.$apollo.mutate({
- mutation: runnerDeleteMutation,
- variables: {
- input: {
- id: this.runner.id,
- },
- },
- update: (cache, { data }) => {
- const { errors } = data.runnerDelete;
-
- if (errors?.length) {
- this.onError(new Error(errors.join(' ')));
- return;
- }
-
- this.$emit('deleted', {
- message: sprintf(I18N_DELETED_TOAST, { name: this.runnerName }),
- });
-
- // Remove deleted runner from the cache
- const cacheId = cache.identify(this.runner);
- cache.evict({ id: cacheId });
- cache.gc();
- },
- });
- } catch (e) {
- this.onError(e);
- }
- },
- onError(error) {
- this.deleting = false;
- const { message } = error;
- const title = sprintf(s__('Runners|Runner %{runnerName} failed to delete'), {
- runnerName: this.runnerName,
- });
-
- createAlert({ title, message });
- captureException({ error, component: this.$options.name });
+ onDone(event) {
+ this.$emit('deleted', event);
},
},
};
</script>
<template>
- <div v-gl-tooltip="tooltip" class="btn-group">
- <gl-button
- v-gl-modal="runnerDeleteModalId"
- :aria-label="ariaLabel"
- :icon="icon"
- :class="buttonClass"
- :loading="deleting"
- variant="danger"
- category="secondary"
- v-bind="$attrs"
- >
- {{ buttonContent }}
- </gl-button>
- <runner-delete-modal
- :modal-id="runnerDeleteModalId"
- :runner-name="runnerName"
- :managers-count="runnerManagersCount"
- @primary="onDelete"
- />
- </div>
+ <runner-delete-action class="btn-group" :runner="runner" @done="onDone">
+ <template #default="{ loading, onClick }">
+ <gl-button
+ v-gl-tooltip="loading ? '' : tooltip"
+ :aria-label="ariaLabel"
+ :icon="icon"
+ :class="buttonClass"
+ :loading="loading"
+ variant="danger"
+ category="secondary"
+ v-bind="$attrs"
+ @click="onClick"
+ >
+ {{ buttonContent }}
+ </gl-button>
+ </template>
+ </runner-delete-action>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_disclosure_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/runner_delete_disclosure_dropdown_item.vue
new file mode 100644
index 00000000000..0a81974a6d0
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_delete_disclosure_dropdown_item.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
+import { I18N_DELETE } from '../constants';
+import RunnerDeleteAction from './runner_delete_action.vue';
+
+export default {
+ name: 'RunnerDeleteDisclosureDropdownItem',
+ components: {
+ GlDisclosureDropdownItem,
+ RunnerDeleteAction,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ },
+ emits: ['deleted'],
+ methods: {
+ onDone(event) {
+ this.$emit('deleted', event);
+ },
+ },
+ I18N_DELETE,
+};
+</script>
+
+<template>
+ <runner-delete-action :runner="runner" @done="onDone">
+ <template #default="{ onClick }">
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <span class="gl-text-red-500">{{ $options.I18N_DELETE }}</span>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </template>
+ </runner-delete-action>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue b/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue
index 93f79fd67ea..124ac0b4e73 100644
--- a/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue
@@ -52,6 +52,9 @@ export default {
},
},
methods: {
+ show() {
+ this.$refs.modal.show();
+ },
onPrimary() {
this.$refs.modal.hide();
},
diff --git a/app/assets/javascripts/ci/runner/components/runner_detail.vue b/app/assets/javascripts/ci/runner/components/runner_detail.vue
index 9e8055a8432..496985ff7ac 100644
--- a/app/assets/javascripts/ci/runner/components/runner_detail.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_detail.vue
@@ -40,12 +40,12 @@ export default {
<template>
<div class="gl-display-contents">
- <dt class="gl-mb-5 gl-mr-6 gl-max-w-26">
+ <dt class="gl-mb-5 gl-mr-6 gl-max-w-26" data-testid="label-slot">
<template v-if="label || $scopedSlots.label">
<slot name="label">{{ label }}</slot>
</template>
</dt>
- <dd class="gl-mb-5">
+ <dd class="gl-mb-5" data-testid="value-slot">
<template v-if="value || $scopedSlots.value">
<slot name="value">{{ value }}</slot>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_edit_button.vue b/app/assets/javascripts/ci/runner/components/runner_edit_button.vue
index 33e0acaf5c0..b4efd72b082 100644
--- a/app/assets/javascripts/ci/runner/components/runner_edit_button.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_edit_button.vue
@@ -9,15 +9,23 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ props: {
+ href: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
I18N_EDIT,
};
</script>
<template>
<gl-button
+ v-if="href"
v-gl-tooltip="$options.I18N_EDIT"
- v-bind="$attrs"
:aria-label="$options.I18N_EDIT"
+ :href="href"
icon="pencil"
v-on="$listeners"
/>
diff --git a/app/assets/javascripts/ci/runner/components/runner_edit_disclosure_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/runner_edit_disclosure_dropdown_item.vue
new file mode 100644
index 00000000000..d0dcc04c3dc
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_edit_disclosure_dropdown_item.vue
@@ -0,0 +1,29 @@
+<script>
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
+
+import { I18N_EDIT } from '../constants';
+
+export default {
+ name: 'RunnerEditDisclosureDropdownItem',
+ components: {
+ GlDisclosureDropdownItem,
+ },
+ props: {
+ href: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ item() {
+ return { text: I18N_EDIT, href: this.href };
+ },
+ },
+ I18N_EDIT,
+};
+</script>
+
+<template>
+ <gl-disclosure-dropdown-item v-if="href" :item="item" />
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_header.vue b/app/assets/javascripts/ci/runner/components/runner_header.vue
index f46e894bf2e..55a33ef2074 100644
--- a/app/assets/javascripts/ci/runner/components/runner_header.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_header.vue
@@ -32,31 +32,29 @@ export default {
};
</script>
<template>
- <div
- class="gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-gap-3 gl-flex-wrap gl-py-5"
- >
- <div>
+ <div class="gl-py-5">
+ <div class="gl-display-flex gl-justify-content-space-between">
<h1 class="gl-font-size-h-display gl-my-0">{{ name }}</h1>
- <div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-flex-wrap gl-mt-3">
- <runner-status-badge :contacted-at="runner.contactedAt" :status="runner.status" />
- <runner-type-badge :type="runner.runnerType" />
- <span v-if="runner.createdAt">
- <gl-sprintf :message="__('%{locked} created %{timeago}')">
- <template #locked>
- <gl-icon
- v-if="runner.locked"
- v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
- name="lock"
- :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
- />
- </template>
- <template #timeago>
- <time-ago :time="runner.createdAt" />
- </template>
- </gl-sprintf>
- </span>
- </div>
+ <slot name="actions"></slot>
+ </div>
+ <div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-flex-wrap gl-mt-3">
+ <runner-status-badge :contacted-at="runner.contactedAt" :status="runner.status" />
+ <runner-type-badge :type="runner.runnerType" />
+ <span v-if="runner.createdAt">
+ <gl-sprintf :message="__('%{locked} created %{timeago}')">
+ <template #locked>
+ <gl-icon
+ v-if="runner.locked"
+ v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
+ name="lock"
+ :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
+ />
+ </template>
+ <template #timeago>
+ <time-ago :time="runner.createdAt" />
+ </template>
+ </gl-sprintf>
+ </span>
</div>
- <div class="gl-display-flex gl-gap-3 gl-flex-wrap"><slot name="actions"></slot></div>
</div>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_header_actions.vue b/app/assets/javascripts/ci/runner/components/runner_header_actions.vue
new file mode 100644
index 00000000000..bc6f184bd4d
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_header_actions.vue
@@ -0,0 +1,80 @@
+<script>
+import { GlDisclosureDropdown } from '@gitlab/ui';
+
+import RunnerDeleteButton from './runner_delete_button.vue';
+import RunnerEditButton from './runner_edit_button.vue';
+import RunnerPauseButton from './runner_pause_button.vue';
+
+import RunnerEditDisclosureDropdownItem from './runner_edit_disclosure_dropdown_item.vue';
+import RunnerPauseDisclosureDropdownItem from './runner_pause_disclosure_dropdown_item.vue';
+import RunnerDeleteDisclosureDropdownItem from './runner_delete_disclosure_dropdown_item.vue';
+
+export default {
+ name: 'RunnerHeaderActions',
+ components: {
+ GlDisclosureDropdown,
+
+ RunnerDeleteButton,
+ RunnerEditButton,
+ RunnerPauseButton,
+
+ RunnerEditDisclosureDropdownItem,
+ RunnerPauseDisclosureDropdownItem,
+ RunnerDeleteDisclosureDropdownItem,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ editPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ canUpdate() {
+ return this.runner.userPermissions?.updateRunner;
+ },
+ canDelete() {
+ return this.runner.userPermissions?.deleteRunner;
+ },
+ },
+ methods: {
+ onDeleted(event) {
+ this.$emit('deleted', event);
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="canUpdate || canDelete">
+ <!-- sm and up screens -->
+ <div class="gl-display-none gl-sm-display-flex gl-gap-3">
+ <runner-edit-button v-if="canUpdate" :href="editPath" />
+ <runner-pause-button v-if="canUpdate" :runner="runner" />
+ <runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" />
+ </div>
+
+ <!-- xs screens -->
+ <div class="gl-sm-display-none">
+ <gl-disclosure-dropdown
+ icon="ellipsis_v"
+ :toggle-text="s__('Runner|Runner actions')"
+ text-sr-only
+ category="tertiary"
+ no-caret
+ >
+ <runner-edit-disclosure-dropdown-item v-if="canUpdate" :href="editPath" />
+ <runner-pause-disclosure-dropdown-item v-if="canUpdate" :runner="runner" />
+ <runner-delete-disclosure-dropdown-item
+ v-if="canDelete"
+ :runner="runner"
+ @deleted="onDeleted"
+ />
+ </gl-disclosure-dropdown>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue
index d2836962a97..a4a489074c3 100644
--- a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue
@@ -11,7 +11,6 @@ import {
I18N_CREATE_RUNNER_LINK,
I18N_STILL_USING_REGISTRATION_TOKENS,
I18N_CONTACT_ADMIN_TO_REGISTER,
- I18N_FOLLOW_REGISTRATION_INSTRUCTIONS,
I18N_NO_RESULTS,
I18N_EDIT_YOUR_SEARCH,
} from '~/ci/runner/constants';
@@ -44,15 +43,6 @@ export default {
default: null,
},
},
- computed: {
- shouldShowCreateRunnerWorkflow() {
- // create_runner_workflow_for_admin or create_runner_workflow_for_namespace
- return (
- this.glFeatures?.createRunnerWorkflowForAdmin ||
- this.glFeatures?.createRunnerWorkflowForNamespace
- );
- },
- },
modalId: 'runners-empty-state-instructions-modal',
svgHeight: 145,
EMPTY_STATE_SVG_URL,
@@ -63,7 +53,6 @@ export default {
I18N_CREATE_RUNNER_LINK,
I18N_STILL_USING_REGISTRATION_TOKENS,
I18N_CONTACT_ADMIN_TO_REGISTER,
- I18N_FOLLOW_REGISTRATION_INSTRUCTIONS,
I18N_NO_RESULTS,
I18N_EDIT_YOUR_SEARCH,
};
@@ -85,39 +74,22 @@ export default {
>
<template #description>
{{ $options.I18N_RUNNERS_ARE_AGENTS }}
- <template v-if="shouldShowCreateRunnerWorkflow">
- <gl-sprintf v-if="newRunnerPath" :message="$options.I18N_CREATE_RUNNER_LINK">
- <template #link="{ content }">
- <gl-link :href="newRunnerPath">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- <template v-if="registrationToken">
- <br />
- <gl-link v-gl-modal="$options.modalId">{{
- $options.I18N_STILL_USING_REGISTRATION_TOKENS
- }}</gl-link>
- <runner-instructions-modal
- :modal-id="$options.modalId"
- :registration-token="registrationToken"
- />
- </template>
- <template v-if="!newRunnerPath && !registrationToken">
- {{ $options.I18N_CONTACT_ADMIN_TO_REGISTER }}
- </template>
- </template>
- <gl-sprintf
- v-else-if="registrationToken"
- :message="$options.I18N_FOLLOW_REGISTRATION_INSTRUCTIONS"
- >
+ <gl-sprintf v-if="newRunnerPath" :message="$options.I18N_CREATE_RUNNER_LINK">
<template #link="{ content }">
- <gl-link v-gl-modal="$options.modalId">{{ content }}</gl-link>
- <runner-instructions-modal
- :modal-id="$options.modalId"
- :registration-token="registrationToken"
- />
+ <gl-link :href="newRunnerPath">{{ content }}</gl-link>
</template>
</gl-sprintf>
- <template v-else>
+ <template v-if="registrationToken">
+ <br />
+ <gl-link v-gl-modal="$options.modalId">{{
+ $options.I18N_STILL_USING_REGISTRATION_TOKENS
+ }}</gl-link>
+ <runner-instructions-modal
+ :modal-id="$options.modalId"
+ :registration-token="registrationToken"
+ />
+ </template>
+ <template v-if="!newRunnerPath && !registrationToken">
{{ $options.I18N_CONTACT_ADMIN_TO_REGISTER }}
</template>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_pause_action.vue b/app/assets/javascripts/ci/runner/components/runner_pause_action.vue
new file mode 100644
index 00000000000..184d6a83381
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_pause_action.vue
@@ -0,0 +1,89 @@
+<script>
+import runnerTogglePausedMutation from '~/ci/runner/graphql/shared/runner_toggle_paused.mutation.graphql';
+import { createAlert } from '~/alert';
+import { captureException } from '~/ci/runner/sentry_utils';
+
+/**
+ * Renderless component that wraps a GraphQL pause mutation for the
+ * runner, given its id and current "paused" value.
+ *
+ * You can use the slot to define a presentation for the delete action,
+ * like a button or dropdown item.
+
+ * Usage:
+ *
+ * ```vue
+ * <runner-pause-action
+ * #default="{ loading, onClick }"
+ * :runner="runner"
+ * @done="onToggled"
+ * >
+ * <button :disabled="loading" @click="onClick">{{ runner.paused ? 'Go!' : 'Stop!' }}</button>
+ * </runner-pause-action>
+ * ```
+ *
+ */
+export default {
+ name: 'RunnerPauseAction',
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ compact: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ emits: ['done'],
+ data() {
+ return {
+ loading: false,
+ };
+ },
+ methods: {
+ async onClick() {
+ this.loading = true;
+ try {
+ const input = {
+ id: this.runner.id,
+ paused: !this.runner.paused,
+ };
+
+ const {
+ data: {
+ runnerUpdate: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: runnerTogglePausedMutation,
+ variables: {
+ input,
+ },
+ });
+
+ if (errors && errors.length) {
+ throw new Error(errors.join(' '));
+ }
+ this.$emit('done');
+ } catch (e) {
+ this.onError(e);
+ } finally {
+ this.loading = false;
+ }
+ },
+ onError(error) {
+ const { message } = error;
+
+ createAlert({ message });
+ captureException({ error, component: this.$options.name });
+ },
+ },
+ render() {
+ return this.$scopedSlots.default({
+ onClick: this.onClick,
+ loading: this.loading,
+ });
+ },
+};
+</script>
diff --git a/app/assets/javascripts/ci/runner/components/runner_pause_button.vue b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue
index d16c8f98bad..15bb54027c7 100644
--- a/app/assets/javascripts/ci/runner/components/runner_pause_button.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue
@@ -1,14 +1,14 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import runnerTogglePausedMutation from '~/ci/runner/graphql/shared/runner_toggle_paused.mutation.graphql';
-import { createAlert } from '~/alert';
-import { captureException } from '~/ci/runner/sentry_utils';
-import { I18N_PAUSE, I18N_PAUSE_TOOLTIP, I18N_RESUME, I18N_RESUME_TOOLTIP } from '../constants';
+
+import { I18N_RESUME, I18N_PAUSE, I18N_PAUSE_TOOLTIP, I18N_RESUME_TOOLTIP } from '../constants';
+import RunnerPauseAction from './runner_pause_action.vue';
export default {
name: 'RunnerPauseButton',
components: {
GlButton,
+ RunnerPauseAction,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -25,96 +25,47 @@ export default {
},
},
emits: ['toggledPaused'],
- data() {
- return {
- updating: false,
- };
- },
computed: {
isPaused() {
return this.runner.paused;
},
+ tooltip() {
+ return this.isPaused ? I18N_RESUME_TOOLTIP : I18N_PAUSE_TOOLTIP;
+ },
icon() {
return this.isPaused ? 'play' : 'pause';
},
label() {
return this.isPaused ? I18N_RESUME : I18N_PAUSE;
},
- buttonContent() {
- if (this.compact) {
- return null;
- }
- return this.label;
- },
ariaLabel() {
if (this.compact) {
return this.label;
}
return null;
},
- tooltip() {
- // Prevent a "sticky" tooltip: If this button is disabled,
- // mouseout listeners don't run leaving the tooltip stuck
- if (!this.updating) {
- return this.isPaused ? I18N_RESUME_TOOLTIP : I18N_PAUSE_TOOLTIP;
- }
- return '';
- },
- },
- methods: {
- async onToggle() {
- this.updating = true;
- try {
- const input = {
- id: this.runner.id,
- paused: !this.isPaused,
- };
-
- const {
- data: {
- runnerUpdate: { errors },
- },
- } = await this.$apollo.mutate({
- mutation: runnerTogglePausedMutation,
- variables: {
- input,
- },
- });
-
- if (errors && errors.length) {
- throw new Error(errors.join(' '));
- }
- this.$emit('toggledPaused');
- } catch (e) {
- this.onError(e);
- } finally {
- this.updating = false;
+ buttonContent() {
+ if (this.compact) {
+ return null;
}
- },
- onError(error) {
- const { message } = error;
-
- createAlert({ message });
- captureException({ error, component: this.$options.name });
+ return this.label;
},
},
};
</script>
<template>
- <gl-button
- v-gl-tooltip="tooltip"
- v-bind="$attrs"
- :aria-label="ariaLabel"
- :icon="icon"
- :loading="updating"
- @click="onToggle"
- v-on="$listeners"
- >
- <!--
- Use <template v-if> to ensure a square button is shown when compact: true.
- Sending empty content will still show a distorted/rectangular button.
- -->
- <template v-if="buttonContent">{{ buttonContent }}</template>
- </gl-button>
+ <runner-pause-action :runner="runner" @done="$emit('toggledPaused')">
+ <template #default="{ loading, onClick }">
+ <gl-button
+ v-gl-tooltip="loading ? '' : tooltip"
+ :icon="icon"
+ :aria-label="ariaLabel"
+ :loading="loading"
+ @click="onClick"
+ >
+ <template v-if="buttonContent">{{ buttonContent }}</template>
+ </gl-button>
+ </template>
+ </runner-pause-action>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_pause_disclosure_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/runner_pause_disclosure_dropdown_item.vue
new file mode 100644
index 00000000000..3dd5e227a4a
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_pause_disclosure_dropdown_item.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
+
+import { I18N_RESUME, I18N_PAUSE } from '../constants';
+import RunnerPauseAction from './runner_pause_action.vue';
+
+export default {
+ name: 'RunnerPauseDisclosureDropdownItem',
+ components: {
+ GlDisclosureDropdownItem,
+ RunnerPauseAction,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ },
+ emits: ['toggledPaused'],
+ computed: {
+ item() {
+ return { text: this.runner.paused ? I18N_RESUME : I18N_PAUSE };
+ },
+ },
+};
+</script>
+
+<template>
+ <runner-pause-action :runner="runner" @done="$emit('toggledPaused')">
+ <template #default="{ onClick }">
+ <gl-disclosure-dropdown-item :item="item" @action="onClick" />
+ </template>
+ </runner-pause-action>
+</template>
diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js
index 40841696ead..203f97876de 100644
--- a/app/assets/javascripts/ci/runner/constants.js
+++ b/app/assets/javascripts/ci/runner/constants.js
@@ -1,4 +1,5 @@
import { __, s__ } from '~/locale';
+import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility';
export const RUNNER_TYPENAME = 'CiRunner'; // __typename
@@ -90,6 +91,7 @@ export const I18N_PAUSED_DESCRIPTION = s__('Runners|Not accepting jobs');
export const I18N_RESUME = __('Resume');
export const I18N_RESUME_TOOLTIP = s__('Runners|Resume accepting jobs');
+export const I18N_DELETE = s__('Runners|Delete');
export const I18N_DELETE_RUNNER = s__('Runners|Delete runner');
export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
@@ -117,9 +119,6 @@ export const I18N_STILL_USING_REGISTRATION_TOKENS = s__('Runners|Still using reg
export const I18N_CONTACT_ADMIN_TO_REGISTER = s__(
'Runners|To register new runners, contact your administrator.',
);
-export const I18N_FOLLOW_REGISTRATION_INSTRUCTIONS = s__(
- 'Runners|Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.',
-);
// No runners found
export const I18N_NO_RESULTS = s__('Runners|No results found');
@@ -271,12 +270,10 @@ export const DEFAULT_PLATFORM = LINUX_PLATFORM;
// Runner docs are in a separate repository and are not shipped with GitLab
// they are rendered as external URLs.
-export const INSTALL_HELP_URL = 'https://docs.gitlab.com/runner/install';
-export const EXECUTORS_HELP_URL = 'https://docs.gitlab.com/runner/executors/';
-export const SERVICE_COMMANDS_HELP_URL =
- 'https://docs.gitlab.com/runner/commands/#service-related-commands';
-export const CHANGELOG_URL = 'https://gitlab.com/gitlab-org/gitlab-runner/blob/main/CHANGELOG.md';
-export const DOCKER_HELP_URL = 'https://docs.gitlab.com/runner/install/docker.html';
-export const KUBERNETES_HELP_URL = 'https://docs.gitlab.com/runner/install/kubernetes.html';
-export const RUNNER_MANAGERS_HELP_URL =
- 'https://docs.gitlab.com/runner/fleet_scaling/#workers-executors-and-autoscaling-capabilities';
+export const INSTALL_HELP_URL = `${DOCS_URL}/runner/install`;
+export const EXECUTORS_HELP_URL = `${DOCS_URL}/runner/executors/`;
+export const SERVICE_COMMANDS_HELP_URL = `${DOCS_URL}/runner/commands/#service-related-commands`;
+export const CHANGELOG_URL = `https://gitlab.com/gitlab-org/gitlab-runner/blob/main/CHANGELOG.md`;
+export const DOCKER_HELP_URL = `${DOCS_URL}/runner/install/docker.html`;
+export const KUBERNETES_HELP_URL = `${DOCS_URL}/runner/install/kubernetes.html`;
+export const RUNNER_MANAGERS_HELP_URL = `${DOCS_URL}/runner/fleet_scaling/#workers-executors-and-autoscaling-capabilities`;
diff --git a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql
index c0b888e758b..7ad9605d0a4 100644
--- a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql
@@ -6,7 +6,6 @@ fragment ListItemShared on CiRunner {
runnerType
shortSha
version
- ipAddress
paused
locked
jobCount
@@ -22,8 +21,11 @@ fragment ListItemShared on CiRunner {
updateRunner
deleteRunner
}
- managers {
+ managers(first: 1) {
count
+ nodes {
+ ipAddress
+ }
}
groups(first: 1) {
nodes {
diff --git a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue
index e885cf45c5a..4b570db772f 100644
--- a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue
+++ b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue
@@ -4,10 +4,8 @@ import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { visitUrl } from '~/lib/utils/url_utility';
-import RunnerDeleteButton from '../components/runner_delete_button.vue';
-import RunnerEditButton from '../components/runner_edit_button.vue';
-import RunnerPauseButton from '../components/runner_pause_button.vue';
import RunnerHeader from '../components/runner_header.vue';
+import RunnerHeaderActions from '../components/runner_header_actions.vue';
import RunnerDetailsTabs from '../components/runner_details_tabs.vue';
import { I18N_FETCH_ERROR } from '../constants';
@@ -18,10 +16,8 @@ import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_lo
export default {
name: 'GroupRunnerShowApp',
components: {
- RunnerDeleteButton,
- RunnerEditButton,
- RunnerPauseButton,
RunnerHeader,
+ RunnerHeaderActions,
RunnerDetailsTabs,
},
props: {
@@ -85,9 +81,11 @@ export default {
<div>
<runner-header v-if="runner" :runner="runner">
<template #actions>
- <runner-edit-button v-if="canUpdate && editGroupRunnerPath" :href="editGroupRunnerPath" />
- <runner-pause-button v-if="canUpdate" :runner="runner" />
- <runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" />
+ <runner-header-actions
+ :runner="runner"
+ :edit-path="editGroupRunnerPath"
+ @deleted="onDeleted"
+ />
</template>
</runner-header>
diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
index 74523bc335f..71584c40a38 100644
--- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
@@ -155,10 +155,6 @@ export default {
isSearchFiltered() {
return isSearchFiltered(this.search);
},
- shouldShowCreateRunnerWorkflow() {
- // create_runner_workflow_for_namespace feature flag
- return this.glFeatures.createRunnerWorkflowForNamespace;
- },
},
watch: {
search: {
@@ -231,11 +227,7 @@ export default {
/>
<div class="gl-w-full gl-md-w-auto gl-display-flex">
- <gl-button
- v-if="shouldShowCreateRunnerWorkflow && newRunnerPath"
- :href="newRunnerPath"
- variant="confirm"
- >
+ <gl-button v-if="newRunnerPath" :href="newRunnerPath" variant="confirm">
{{ s__('Runners|New group runner') }}
</gl-button>
<registration-dropdown
@@ -243,7 +235,7 @@ export default {
class="gl-ml-3"
:registration-token="registrationToken"
:type="$options.GROUP_TYPE"
- right
+ placement="right"
/>
</div>
</div>
diff --git a/app/assets/javascripts/clusters/forms/show/index.js b/app/assets/javascripts/clusters/forms/show/index.js
index 102b240042f..3cb4376f41a 100644
--- a/app/assets/javascripts/clusters/forms/show/index.js
+++ b/app/assets/javascripts/clusters/forms/show/index.js
@@ -1,11 +1,8 @@
import Vue from 'vue';
-import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import IntegrationForm from '../components/integration_form.vue';
import { createStore } from '../stores';
export default () => {
- dirtySubmitFactory(document.querySelectorAll('.js-cluster-integrations-form'));
-
const entryPoint = document.querySelector('#js-cluster-details-form');
if (!entryPoint) {
diff --git a/app/assets/javascripts/clusters_list/components/clusters_actions.vue b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
index 2675d46dd16..7b97a5af373 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_actions.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
@@ -1,5 +1,11 @@
<script>
-import { GlButton, GlDropdown, GlDropdownItem, GlModalDirective, GlTooltip } from '@gitlab/ui';
+import {
+ GlButton,
+ GlButtonGroup,
+ GlModalDirective,
+ GlTooltip,
+ GlDisclosureDropdown,
+} from '@gitlab/ui';
import { INSTALL_AGENT_MODAL_ID, CLUSTERS_ACTIONS } from '../constants';
@@ -8,8 +14,8 @@ export default {
INSTALL_AGENT_MODAL_ID,
components: {
GlButton,
- GlDropdown,
- GlDropdownItem,
+ GlButtonGroup,
+ GlDisclosureDropdown,
GlTooltip,
},
directives: {
@@ -45,13 +51,17 @@ export default {
actionItems() {
const createCluster = {
href: this.newClusterDocsPath,
- title: this.$options.i18n.createCluster,
- testid: 'create-cluster-link',
+ text: this.$options.i18n.createCluster,
+ extraAttrs: {
+ 'data-testid': 'create-cluster-link',
+ },
};
const connectCluster = {
href: this.addClusterPath,
- title: this.$options.i18n.connectClusterCertificate,
- testid: 'connect-cluster-link',
+ text: this.$options.i18n.connectClusterCertificate,
+ extraAttrs: {
+ 'data-testid': 'connect-cluster-link',
+ },
};
const actions = [];
@@ -61,7 +71,6 @@ export default {
if (this.displayClusterAgents && this.certificateBasedClustersEnabled) {
actions.push(connectCluster);
}
-
return actions;
},
},
@@ -81,39 +90,35 @@ export default {
:title="$options.i18n.actionsDisabledHint"
/>
- <gl-button
- v-if="!actionItems.length"
- data-qa-selector="clusters_actions_button"
- category="primary"
- variant="confirm"
- :disabled="!canAddCluster"
- :href="defaultActionUrl"
- >
- {{ defaultActionText }}
- </gl-button>
-
- <gl-dropdown
- v-else
+ <!--TODO: Replace button-group workaround once `split` option for new dropdowns is implemented.-->
+ <!-- See issue at https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2263-->
+ <gl-button-group
ref="actions"
- v-gl-modal-directive="shouldTriggerModal && $options.INSTALL_AGENT_MODAL_ID"
data-qa-selector="clusters_actions_button"
- category="primary"
- variant="confirm"
- :text="defaultActionText"
- :disabled="!canAddCluster"
- :split-href="defaultActionUrl"
- split
- right
+ class="gl-w-full gl-mb-3 gl-md-w-auto gl-md-mb-0"
>
- <gl-dropdown-item
- v-for="action in actionItems"
- :key="action.title"
- :href="action.href"
- :data-testid="action.testid"
- @click.stop
+ <gl-button
+ v-gl-modal-directive="shouldTriggerModal && $options.INSTALL_AGENT_MODAL_ID"
+ :href="defaultActionUrl"
+ :disabled="!canAddCluster"
+ data-testid="clusters-default-action-button"
+ category="primary"
+ variant="confirm"
>
- {{ action.title }}
- </gl-dropdown-item>
- </gl-dropdown>
+ {{ defaultActionText }}
+ </gl-button>
+ <gl-disclosure-dropdown
+ v-if="actionItems.length"
+ class="split"
+ toggle-class="gl-rounded-top-left-none! gl-rounded-bottom-left-none! gl-pl-1!"
+ category="primary"
+ variant="confirm"
+ placement="right"
+ :toggle-text="defaultActionText"
+ :items="actionItems"
+ :disabled="!canAddCluster"
+ text-sr-only
+ />
+ </gl-button-group>
</div>
</template>
diff --git a/app/assets/javascripts/comment_templates/components/app.vue b/app/assets/javascripts/comment_templates/components/app.vue
index 9e0d2cc73ec..de3229acc78 100644
--- a/app/assets/javascripts/comment_templates/components/app.vue
+++ b/app/assets/javascripts/comment_templates/components/app.vue
@@ -3,21 +3,21 @@ export default {};
</script>
<template>
- <div class="row gl-mt-5">
- <div class="col-lg-4">
- <h4 class="gl-mt-0">
- {{ __('Comment templates') }}
- </h4>
- <p>
- {{
- __(
- 'Comment templates can be used when creating comments inside issues, merge requests, and epics.',
- )
- }}
- </p>
- </div>
- <div class="col-lg-8">
- <keep-alive><router-view /></keep-alive>
+ <div class="settings-section gl-mt-3">
+ <div class="settings-sticky-header">
+ <div class="settings-sticky-header-inner">
+ <h4 class="gl-my-0">
+ {{ __('Comment templates') }}
+ </h4>
+ </div>
</div>
+ <p class="gl-text-secondary">
+ {{
+ __(
+ 'Comment templates can be used when creating comments inside issues, merge requests, and epics.',
+ )
+ }}
+ </p>
+ <keep-alive><router-view /></keep-alive>
</div>
</template>
diff --git a/app/assets/javascripts/comment_templates/components/form.vue b/app/assets/javascripts/comment_templates/components/form.vue
index 47efccc3d0c..6bdf1b313cb 100644
--- a/app/assets/javascripts/comment_templates/components/form.vue
+++ b/app/assets/javascripts/comment_templates/components/form.vue
@@ -112,7 +112,7 @@ export default {
<template>
<gl-form
- class="new-note common-note-form gl-mb-6"
+ class="new-note common-note-form"
data-testid="comment-template-form"
@submit.prevent="onSubmit"
>
diff --git a/app/assets/javascripts/comment_templates/components/list.vue b/app/assets/javascripts/comment_templates/components/list.vue
index 52bebfd050c..46d6b49297d 100644
--- a/app/assets/javascripts/comment_templates/components/list.vue
+++ b/app/assets/javascripts/comment_templates/components/list.vue
@@ -44,14 +44,18 @@ export default {
</script>
<template>
- <div class="gl-border-t gl-pt-4">
+ <div class="settings-section">
<gl-loading-icon v-if="loading" size="lg" />
<template v-else>
- <h5 class="gl-font-lg" data-testid="title">
- <gl-sprintf :message="__('My comment templates (%{count})')">
- <template #count>{{ count }}</template>
- </gl-sprintf>
- </h5>
+ <div class="settings-sticky-header">
+ <div class="settings-sticky-header-inner">
+ <h4 class="gl-my-0" data-testid="title">
+ <gl-sprintf :message="__('My comment templates (%{count})')">
+ <template #count>{{ count }}</template>
+ </gl-sprintf>
+ </h4>
+ </div>
+ </div>
<ul class="gl-list-style-none gl-p-0 gl-m-0">
<list-item v-for="template in savedReplies" :key="template.id" :template="template" />
</ul>
diff --git a/app/assets/javascripts/comment_templates/components/list_item.vue b/app/assets/javascripts/comment_templates/components/list_item.vue
index d763700db42..70ba449113b 100644
--- a/app/assets/javascripts/comment_templates/components/list_item.vue
+++ b/app/assets/javascripts/comment_templates/components/list_item.vue
@@ -94,7 +94,7 @@ export default {
</gl-tooltip>
</div>
</div>
- <div class="gl-mt-3 gl-font-monospace">{{ template.content }}</div>
+ <div class="gl-mt-3 gl-font-monospace gl-white-space-pre-wrap">{{ template.content }}</div>
<gl-modal
ref="delete-modal"
:title="__('Delete comment template')"
diff --git a/app/assets/javascripts/comment_templates/pages/index.vue b/app/assets/javascripts/comment_templates/pages/index.vue
index 72a94dafc58..daa4ba689a7 100644
--- a/app/assets/javascripts/comment_templates/pages/index.vue
+++ b/app/assets/javascripts/comment_templates/pages/index.vue
@@ -52,10 +52,12 @@ export default {
<template>
<div>
- <h5 class="gl-mt-0 gl-font-lg">
- {{ __('Add new comment template') }}
- </h5>
- <create-form @saved="refetchSavedReplies" />
+ <div class="settings-section">
+ <h5 class="gl-mt-0 gl-font-lg">
+ {{ __('Add new comment template') }}
+ </h5>
+ <create-form @saved="refetchSavedReplies" />
+ </div>
<list
:loading="$apollo.queries.savedReplies.loading"
:saved-replies="savedReplies"
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue
index ce5b566ba20..948c58287fb 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue
@@ -44,7 +44,7 @@ export default {
this.menuVisible = false;
},
strategy: 'fixed',
- maxWidth: 'auto',
+ maxWidth: '400px',
},
}),
);
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue
index 6bb6bdc4e65..6ce6e731551 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue
@@ -69,7 +69,6 @@ export default {
mediaSrc: undefined,
mediaCanonicalSrc: undefined,
mediaAlt: undefined,
- mediaTitle: undefined,
isEditing: false,
isUpdating: false,
@@ -130,16 +129,13 @@ export default {
const position = this.tiptapEditor.state.selection.from;
- this.tiptapEditor
- .chain()
- .focus()
- .updateAttributes(this.mediaType, {
- src: this.mediaSrc,
- alt: this.mediaAlt,
- canonicalSrc: this.mediaCanonicalSrc,
- title: this.mediaTitle,
- })
- .run();
+ const attrs = {
+ src: this.mediaSrc,
+ alt: this.mediaAlt,
+ canonicalSrc: this.mediaCanonicalSrc,
+ };
+
+ this.tiptapEditor.chain().focus().updateAttributes(this.mediaType, attrs).run();
this.tiptapEditor.commands.setNodeSelection(position);
@@ -155,13 +151,11 @@ export default {
this.isUpdating = true;
- const { src, title, alt, canonicalSrc, uploading } = this.tiptapEditor.getAttributes(
- this.mediaType,
- );
+ const { src, alt, canonicalSrc, uploading } = this.tiptapEditor.getAttributes(this.mediaType);
- this.mediaTitle = title;
this.mediaAlt = alt;
this.mediaCanonicalSrc = canonicalSrc || src;
+
this.uploading = uploading;
this.mediaSrc = await this.contentEditor.resolveUrl(this.mediaCanonicalSrc);
@@ -177,7 +171,6 @@ export default {
},
resetMediaInfo() {
- this.mediaTitle = null;
this.mediaAlt = null;
this.mediaCanonicalSrc = null;
this.uploading = false;
@@ -248,7 +241,6 @@ export default {
data-qa-selector="file_upload_field"
@change="onFileSelect"
/>
-
<gl-link
v-if="!showProgressIndicator"
v-gl-tooltip
@@ -261,17 +253,6 @@ export default {
{{ mediaCanonicalSrc }}
</gl-link>
<gl-button
- v-gl-tooltip
- variant="default"
- category="tertiary"
- size="medium"
- data-testid="copy-media-src"
- :aria-label="copySourceLabel"
- :title="copySourceLabel"
- icon="copy-to-clipboard"
- @click="copyMediaSrc"
- />
- <gl-button
v-if="!showProgressIndicator"
v-gl-tooltip
variant="default"
@@ -290,8 +271,8 @@ export default {
category="tertiary"
size="medium"
data-testid="edit-diagram"
- :aria-label="replaceLabel"
- title="Edit diagram"
+ :aria-label="editLabel"
+ :title="editLabel"
icon="diagram"
@click="editDiagram"
/>
@@ -307,28 +288,14 @@ export default {
icon="retry"
@click="replaceMedia"
/>
- <gl-button
- v-gl-tooltip
- variant="default"
- category="tertiary"
- size="medium"
- data-testid="delete-media"
- :aria-label="deleteLabel"
- :title="deleteLabel"
- icon="remove"
- @click="deleteMedia"
- />
</gl-button-group>
<gl-form v-else class="bubble-menu-form gl-p-4 gl-w-100" @submit.prevent="saveEditedMedia">
<gl-form-group :label="__('URL')" label-for="media-src">
<gl-form-input id="media-src" v-model="mediaCanonicalSrc" data-testid="media-src" />
</gl-form-group>
- <gl-form-group :label="__('Description (alt text)')" label-for="media-alt">
+ <gl-form-group :label="__('Alt text')" label-for="media-alt">
<gl-form-input id="media-alt" v-model="mediaAlt" data-testid="media-alt" />
</gl-form-group>
- <gl-form-group :label="__('Title')" label-for="media-title">
- <gl-form-input id="media-title" v-model="mediaTitle" data-testid="media-title" />
- </gl-form-group>
<div class="gl-display-flex gl-justify-content-end">
<gl-button
class="gl-mr-3"
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 92f3c3fb8fa..1036b6552d1 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -1,8 +1,9 @@
<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
-import { GlSprintf, GlLink } from '@gitlab/ui';
-import { __, s__ } from '~/locale';
+import { __ } from '~/locale';
import { VARIANT_DANGER } from '~/alert';
+import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue';
import { createContentEditor } from '../services/create_content_editor';
import { ALERT_EVENT, TIPTAP_AUTOFOCUS_OPTIONS } from '../constants';
import ContentEditorAlert from './content_editor_alert.vue';
@@ -17,8 +18,7 @@ import LoadingIndicator from './loading_indicator.vue';
export default {
components: {
- GlSprintf,
- GlLink,
+ GlButton,
LoadingIndicator,
ContentEditorAlert,
ContentEditorProvider,
@@ -29,12 +29,20 @@ export default {
MediaBubbleMenu,
EditorStateObserver,
ReferenceBubbleMenu,
+ EditorModeSwitcher,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
renderMarkdown: {
type: Function,
required: true,
},
+ markdownDocsPath: {
+ type: String,
+ required: true,
+ },
uploadsPath: {
type: String,
required: true,
@@ -65,16 +73,21 @@ export default {
default: false,
validator: (autofocus) => TIPTAP_AUTOFOCUS_OPTIONS.includes(autofocus),
},
- quickActionsDocsPath: {
- type: String,
+ supportsQuickActions: {
+ type: Boolean,
required: false,
- default: '',
+ default: false,
},
drawioEnabled: {
type: Boolean,
required: false,
default: false,
},
+ codeSuggestionsConfig: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
editable: {
type: Boolean,
required: false,
@@ -129,6 +142,7 @@ export default {
editable,
enableAutocomplete,
autocompleteDataSources,
+ codeSuggestionsConfig,
} = this;
// This is a non-reactive attribute intentionally since this is a complex object.
@@ -140,6 +154,7 @@ export default {
drawioEnabled,
enableAutocomplete,
autocompleteDataSources,
+ codeSuggestionsConfig,
tiptapOptions: {
autofocus,
editable,
@@ -204,17 +219,15 @@ export default {
markdown: this.latestMarkdown,
});
},
- },
- i18n: {
- quickActionsText: s__(
- 'ContentEditor|For %{quickActionsDocsLinkStart}quick actions%{quickActionsDocsLinkEnd}, type %{keyboardStart}/%{keyboardEnd}.',
- ),
+ handleEditorModeChanged() {
+ this.$emit('enableMarkdownEditor');
+ },
},
};
</script>
<template>
<content-editor-provider :content-editor="contentEditor">
- <div>
+ <div class="md-area gl-overflow-hidden">
<editor-state-observer
@docUpdate="notifyChange"
@focus="focus"
@@ -225,11 +238,11 @@ export default {
<div
data-testid="content-editor"
data-qa-selector="content_editor_container"
- class="md-area gl-border-none! gl-shadow-none!"
:class="{ 'is-focused': focused }"
>
<formatting-toolbar
ref="toolbar"
+ :supports-quick-actions="supportsQuickActions"
:hide-attachment-button="disableAttachments"
@enableMarkdownEditor="$emit('enableMarkdownEditor')"
/>
@@ -237,7 +250,7 @@ export default {
{{ placeholder }}
</div>
<tiptap-editor-content
- class="md gl-px-5"
+ class="md"
data-testid="content_editor_editablebox"
:editor="contentEditor.tiptapEditor"
/>
@@ -249,21 +262,19 @@ export default {
<reference-bubble-menu />
</div>
<div
- v-if="quickActionsDocsPath"
- class="gl-display-flex gl-align-items-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-4 gl-mx-2 gl-mb-2 gl-bg-gray-10 gl-text-secondary"
+ class="gl-display-flex gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-2 gl-border-t gl-border-gray-100 gl-text-secondary"
>
- <div class="gl-w-full gl-line-height-32 gl-font-sm">
- <gl-sprintf :message="$options.i18n.quickActionsText">
- <template #keyboard="{ content }">
- <kbd>{{ content }}</kbd>
- </template>
- <template #quickActionsDocsLink="{ content }">
- <gl-link :href="quickActionsDocsPath" target="_blank" class="gl-font-sm">{{
- content
- }}</gl-link>
- </template>
- </gl-sprintf>
- </div>
+ <editor-mode-switcher size="small" value="richText" @switch="handleEditorModeChanged" />
+ <gl-button
+ v-gl-tooltip
+ icon="markdown-mark"
+ :href="markdownDocsPath"
+ target="_blank"
+ category="tertiary"
+ size="small"
+ title="Markdown is supported"
+ class="gl-px-3!"
+ />
</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 c53007b68cf..dc27278d255 100644
--- a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
@@ -1,5 +1,7 @@
<script>
-import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue';
+import CommentTemplatesDropdown from '~/vue_shared/components/markdown/comment_templates_dropdown.vue';
+import { __, sprintf } from '~/locale';
+import { getModifierKey } from '~/constants';
import trackUIControl from '../services/track_ui_control';
import ToolbarButton from './toolbar_button.vue';
import ToolbarAttachmentButton from './toolbar_attachment_button.vue';
@@ -14,122 +16,179 @@ export default {
ToolbarTableButton,
ToolbarAttachmentButton,
ToolbarMoreDropdown,
- EditorModeSwitcher,
+ CommentTemplatesDropdown,
+ },
+ inject: {
+ newCommentTemplatePath: { default: null },
+ tiptapEditor: { default: null },
+ contentEditor: { default: null },
},
props: {
+ supportsQuickActions: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
hideAttachmentButton: {
type: Boolean,
default: false,
required: false,
},
},
+ data() {
+ const modifierKey = getModifierKey();
+ const shiftKey = modifierKey === '⌘' ? '⇧' : 'Shift+';
+
+ return {
+ i18n: {
+ bold: sprintf(__('Bold (%{modifierKey}B)'), { modifierKey }),
+ italic: sprintf(__('Italic (%{modifierKey}I)'), { modifierKey }),
+ strike: sprintf(__('Strikethrough (%{modifierKey}%{shiftKey}X)'), {
+ modifierKey,
+ shiftKey,
+ }),
+ quote: __('Insert a quote'),
+ code: __('Code'),
+ link: sprintf(__('Insert link (%{modifierKey}K)'), { modifierKey }),
+ bulletList: __('Add a bullet list'),
+ numberedList: __('Add a numbered list'),
+ taskList: __('Add a checklist'),
+ },
+ };
+ },
+ computed: {
+ codeSuggestionsEnabled() {
+ return this.contentEditor.codeSuggestionsConfig?.canSuggest;
+ },
+ },
methods: {
trackToolbarControlExecution({ contentType, value }) {
trackUIControl({ property: contentType, value });
},
- handleEditorModeChanged() {
- this.$emit('enableMarkdownEditor');
+ insertSavedReply(savedReply) {
+ this.tiptapEditor.chain().focus().pasteContent(savedReply).run();
},
},
};
</script>
<template>
- <div class="gl-mx-2 gl-mt-2">
- <div
- class="gl-w-full gl-display-flex gl-align-items-center gl-flex-wrap gl-bg-gray-50 gl-px-2 gl-rounded-base gl-justify-content-space-between"
- data-testid="formatting-toolbar"
- >
- <div class="gl-py-2 gl-display-flex gl-flex-wrap">
- <toolbar-text-style-dropdown
- data-testid="text-styles"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="bold"
- content-type="bold"
- icon-name="bold"
- editor-command="toggleBold"
- :label="__('Bold text')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="italic"
- content-type="italic"
- icon-name="italic"
- editor-command="toggleItalic"
- :label="__('Italic text')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="strike"
- content-type="strike"
- icon-name="strikethrough"
- editor-command="toggleStrike"
- :label="__('Strikethrough')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="blockquote"
- content-type="blockquote"
- icon-name="quote"
- editor-command="toggleBlockquote"
- :label="__('Insert a quote')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="code"
- content-type="code"
- icon-name="code"
- editor-command="toggleCode"
- :label="__('Code')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="link"
- content-type="link"
- icon-name="link"
- editor-command="editLink"
- :label="__('Insert link')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="bullet-list"
- content-type="bulletList"
- icon-name="list-bulleted"
- class="gl-display-none gl-sm-display-inline"
- editor-command="toggleBulletList"
- :label="__('Add a bullet list')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="ordered-list"
- content-type="orderedList"
- icon-name="list-numbered"
- class="gl-display-none gl-sm-display-inline"
- editor-command="toggleOrderedList"
- :label="__('Add a numbered list')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="task-list"
- content-type="taskList"
- icon-name="list-task"
- class="gl-display-none gl-sm-display-inline"
- editor-command="toggleTaskList"
- :label="__('Add a checklist')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" />
- <toolbar-attachment-button
- v-if="!hideAttachmentButton"
- data-testid="attachment"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" />
- </div>
- <div class="content-editor-switcher gl-display-flex gl-align-items-center gl-ml-auto">
- <editor-mode-switcher size="small" value="richText" @input="handleEditorModeChanged" />
- </div>
+ <div
+ class="gl-w-full gl-display-flex gl-align-items-center gl-flex-wrap gl-border-b gl-border-gray-100 gl-px-3 gl-rounded-top-base gl-justify-content-space-between"
+ data-testid="formatting-toolbar"
+ >
+ <div class="gl-py-3 gl-display-flex gl-flex-wrap">
+ <toolbar-text-style-dropdown
+ data-testid="text-styles"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ v-if="codeSuggestionsEnabled"
+ data-testid="code-suggestion"
+ content-type="codeSuggestion"
+ icon-name="doc-code"
+ editor-command="insertCodeSuggestion"
+ :label="__('Insert suggestion')"
+ :show-active-state="false"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="bold"
+ content-type="bold"
+ icon-name="bold"
+ editor-command="toggleBold"
+ :label="i18n.bold"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="italic"
+ content-type="italic"
+ icon-name="italic"
+ editor-command="toggleItalic"
+ :label="i18n.italic"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="strike"
+ content-type="strike"
+ icon-name="strikethrough"
+ editor-command="toggleStrike"
+ :label="i18n.strike"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="blockquote"
+ content-type="blockquote"
+ icon-name="quote"
+ editor-command="toggleBlockquote"
+ :label="i18n.quote"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="code"
+ content-type="code"
+ icon-name="code"
+ editor-command="toggleCode"
+ :label="i18n.code"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="link"
+ content-type="link"
+ icon-name="link"
+ editor-command="editLink"
+ :label="i18n.link"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="bullet-list"
+ content-type="bulletList"
+ icon-name="list-bulleted"
+ class="gl-display-none gl-sm-display-inline"
+ editor-command="toggleBulletList"
+ :label="i18n.bulletList"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="ordered-list"
+ content-type="orderedList"
+ icon-name="list-numbered"
+ class="gl-display-none gl-sm-display-inline"
+ editor-command="toggleOrderedList"
+ :label="i18n.numberedList"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="task-list"
+ content-type="taskList"
+ icon-name="list-task"
+ class="gl-display-none gl-sm-display-inline"
+ editor-command="toggleTaskList"
+ :label="i18n.taskList"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" />
+ <toolbar-attachment-button
+ v-if="!hideAttachmentButton"
+ data-testid="attachment"
+ @execute="trackToolbarControlExecution"
+ />
+ <!-- TODO Add icon and trigger functionality from here -->
+ <toolbar-button
+ v-if="supportsQuickActions"
+ data-testid="quick-actions"
+ content-type="quickAction"
+ icon-name="quick-actions"
+ class="gl-display-none gl-sm-display-inline"
+ editor-command="insertQuickAction"
+ :label="__('Add a quick action')"
+ @execute="trackToolbarControlExecution"
+ />
+ <comment-templates-dropdown
+ v-if="newCommentTemplatePath"
+ :new-comment-template-path="newCommentTemplatePath"
+ @select="insertSavedReply"
+ />
+ <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue
index 4074e50a706..6535d9eaa5d 100644
--- a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue
@@ -1,9 +1,8 @@
<script>
-import { GlDropdownItem, GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui';
+import { GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
- GlDropdownItem,
GlAvatarLabeled,
GlLoadingIcon,
},
@@ -43,7 +42,7 @@ export default {
data() {
return {
- selectedIndex: 0,
+ selectedIndex: -1,
};
},
@@ -95,7 +94,7 @@ export default {
watch: {
items() {
- this.selectedIndex = 0;
+ this.selectedIndex = -1;
},
selectedIndex() {
this.scrollIntoView();
@@ -193,7 +192,7 @@ export default {
},
scrollIntoView() {
- this.$refs.dropdownItems[this.selectedIndex].$el.scrollIntoView({ block: 'nearest' });
+ this.$refs.dropdownItems[this.selectedIndex]?.scrollIntoView({ block: 'nearest' });
},
selectItem(index) {
@@ -215,72 +214,83 @@ export default {
</script>
<template>
- <div>
- <ul
- v-if="!loading"
- :class="{ show: items.length > 0 }"
- class="gl-dropdown dropdown-menu gl-relative gl-m-0!"
- data-testid="content-editor-suggestions-dropdown"
+ <div class="gl-new-dropdown content-editor-suggestions-dropdown">
+ <div
+ v-if="!loading && items.length > 0"
+ class="gl-new-dropdown-panel gl-display-block! gl-absolute"
>
- <div class="gl-dropdown-inner gl-overflow-y-auto">
- <gl-dropdown-item
- v-for="(item, index) in items"
- ref="dropdownItems"
- :key="index"
- :class="{ 'gl-bg-gray-50': index === selectedIndex }"
- @click="selectItem(index)"
- >
- <gl-avatar-labeled
- v-if="isUser"
- :label="item.username"
- :sub-label="avatarSubLabel(item)"
- :src="item.avatar_url"
- :entity-name="item.username"
- :shape="item.type === 'Group' ? 'rect' : 'circle'"
- :size="32"
- />
- <span v-if="isIssue || isMergeRequest">
- <small>{{ item.iid }}</small>
- {{ item.title }}
- </span>
- <span v-if="isVulnerability || isSnippet">
- <small>{{ item.id }}</small>
- {{ item.title }}
- </span>
- <span v-if="isEpic">
- <small>{{ item.reference }}</small>
- {{ item.title }}
- </span>
- <span v-if="isMilestone">
- {{ item.title }}
- </span>
- <span v-if="isLabel" class="gl-display-flex gl-align-items-center">
- <span
- data-testid="label-color-box"
- class="gl-rounded-base gl-display-block gl-w-5 gl-h-5 gl-mr-3"
- :style="{ backgroundColor: item.color }"
- ></span>
- {{ item.title }}
- </span>
- <span v-if="isCommand">
- /{{ item.name }} <small> {{ item.params[0] }} </small><br />
- <em>
- <small> {{ item.description }} </small>
- </em>
- </span>
- <div v-if="isEmoji" class="gl-display-flex gl-align-items-center">
- <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div>
- <div class="gl-flex-grow-1">
- {{ item.name }}<br />
- <small>{{ item.d }}</small>
+ <div class="gl-new-dropdown-inner">
+ <ul class="gl-new-dropdown-contents" data-testid="content-editor-suggestions-dropdown">
+ <li
+ v-for="(item, index) in items"
+ :key="index"
+ role="presentation"
+ class="gl-new-dropdown-item"
+ :class="{ focused: index === selectedIndex }"
+ >
+ <div
+ ref="dropdownItems"
+ type="button"
+ role="menuitem"
+ class="gl-new-dropdown-item-content"
+ @click="selectItem(index)"
+ >
+ <div class="gl-new-dropdown-item-text-wrapper">
+ <gl-avatar-labeled
+ v-if="isUser"
+ :label="item.username"
+ :sub-label="avatarSubLabel(item)"
+ :src="item.avatar_url"
+ :entity-name="item.username"
+ :shape="item.type === 'Group' ? 'rect' : 'circle'"
+ :size="32"
+ />
+ <span v-if="isIssue || isMergeRequest">
+ <small>{{ item.iid }}</small>
+ {{ item.title }}
+ </span>
+ <span v-if="isVulnerability || isSnippet">
+ <small>{{ item.id }}</small>
+ {{ item.title }}
+ </span>
+ <span v-if="isEpic">
+ <small>{{ item.reference }}</small>
+ {{ item.title }}
+ </span>
+ <span v-if="isMilestone">
+ {{ item.title }}
+ </span>
+ <span v-if="isLabel" class="gl-display-flex">
+ <span
+ data-testid="label-color-box"
+ class="dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3"
+ :style="{ backgroundColor: item.color }"
+ ></span>
+ {{ item.title }}
+ </span>
+ <div v-if="isCommand">
+ <div class="gl-mb-1">
+ <span class="gl-font-weight-bold">/{{ item.name }}</span>
+ <em class="gl-text-gray-500 gl-font-sm">{{ item.params[0] }}</em>
+ </div>
+ <small class="gl-text-gray-500"> {{ item.description }} </small>
+ </div>
+ <div v-if="isEmoji" class="gl-display-flex gl-align-items-center">
+ <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div>
+ <div class="gl-flex-grow-1">
+ {{ item.name }}<br />
+ <small>{{ item.d }}</small>
+ </div>
+ </div>
+ </div>
</div>
- </div>
- </gl-dropdown-item>
+ </li>
+ </ul>
</div>
- </ul>
- <div v-if="loading" class="gl-dropdown show dropdown-menu gl-relative gl-m-0!">
- <div class="gl-dropdown-inner gl-overflow-y-auto">
- <div class="gl-px-5">
+ </div>
+ <div v-if="loading" class="gl-new-dropdown-panel gl-display-block! gl-absolute">
+ <div class="gl-new-dropdown-inner">
+ <div class="gl-px-4 gl-py-3">
<gl-loading-icon size="sm" class="gl-display-inline-block" /> {{ __('Loading...') }}
</div>
</div>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue
index 1e13c17bc38..4cf150dd948 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue
@@ -47,6 +47,7 @@ export default {
category="tertiary"
icon="paperclip"
size="small"
+ class="gl-mr-3"
lazy
@click="openFileUpload"
/>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_button.vue b/app/assets/javascripts/content_editor/components/toolbar_button.vue
index a62f66d8557..60bfaab25a5 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_button.vue
@@ -49,6 +49,11 @@ export default {
required: false,
default: 'small',
},
+ showActiveState: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -78,7 +83,7 @@ export default {
:variant="variant"
:category="category"
:size="size"
- :class="{ 'gl-bg-gray-100!': isActive }"
+ :class="{ 'gl-bg-gray-100!': showActiveState && isActive }"
:aria-label="label"
:title="label"
:icon="iconName"
diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
index 99ba8c51948..b7f419d5840 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
@@ -15,10 +15,6 @@ export default {
toggleId: uniqueId('dropdown-toggle-btn-'),
items: [
{
- text: __('Comment'),
- action: () => this.insert('comment'),
- },
- {
text: __('Code block'),
action: () => this.insert('codeBlock'),
},
diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
index eb7985f628a..ab1546b9016 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
@@ -1,5 +1,6 @@
<script>
-import { GlDisclosureDropdown, GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import { GlDisclosureDropdown, GlButton, GlTooltip } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
import { __, sprintf } from '~/locale';
import { clamp } from '../services/utils';
@@ -14,13 +15,12 @@ export default {
components: {
GlButton,
GlDisclosureDropdown,
- },
- directives: {
GlTooltip,
},
inject: ['tiptapEditor'],
data() {
return {
+ toggleId: uniqueId('dropdown-toggle-btn-'),
maxRows: MIN_ROWS,
maxCols: MIN_COLS,
rows: 1,
@@ -82,43 +82,47 @@ export default {
};
</script>
<template>
- <gl-disclosure-dropdown
- ref="dropdown"
- v-gl-tooltip
- size="small"
- category="tertiary"
- icon="table"
- :aria-label="__('Insert table')"
- :toggle-text="__('Insert table')"
- positioning-strategy="fixed"
- class="content-editor-table-dropdown"
- text-sr-only
- :fluid-width="true"
- @shown="setFocus(1, 1)"
- >
- <div
- class="gl-p-3 gl-pt-2"
- role="grid"
- :aria-colcount="$options.MAX_COLS"
- :aria-rowcount="$options.MAX_ROWS"
+ <div class="gl-display-inline-flex gl-vertical-align-middle">
+ <gl-disclosure-dropdown
+ ref="dropdown"
+ :toggle-id="toggleId"
+ size="small"
+ category="tertiary"
+ icon="table"
+ no-caret
+ :aria-label="__('Insert table')"
+ :toggle-text="__('Insert table')"
+ positioning-strategy="fixed"
+ class="content-editor-table-dropdown gl-mr-3"
+ text-sr-only
+ :fluid-width="true"
+ @shown="setFocus(1, 1)"
>
- <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex" role="row">
- <div v-for="c of list(maxCols)" :key="c" role="gridcell">
- <gl-button
- :ref="`table-${r}-${c}`"
- :class="{ 'active gl-bg-blue-50!': r <= rows && c <= cols }"
- :aria-label="getButtonLabel(r, c)"
- class="table-creator-grid-item gl-display-inline gl-rounded-0! gl-w-6! gl-h-6! gl-p-0!"
- @mouseover="setRowsAndCols(r, c)"
- @focus="setRowsAndCols(r, c)"
- @click="insertTable()"
- @keydown="onKeydown($event.key)"
- />
+ <div
+ class="gl-p-3 gl-pt-2"
+ role="grid"
+ :aria-colcount="$options.MAX_COLS"
+ :aria-rowcount="$options.MAX_ROWS"
+ >
+ <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex" role="row">
+ <div v-for="c of list(maxCols)" :key="c" role="gridcell">
+ <gl-button
+ :ref="`table-${r}-${c}`"
+ :class="{ 'active gl-bg-blue-50!': r <= rows && c <= cols }"
+ :aria-label="getButtonLabel(r, c)"
+ class="table-creator-grid-item gl-display-inline gl-rounded-0! gl-w-6! gl-h-6! gl-p-0!"
+ @mouseover="setRowsAndCols(r, c)"
+ @focus="setRowsAndCols(r, c)"
+ @click="insertTable()"
+ @keydown="onKeydown($event.key)"
+ />
+ </div>
</div>
</div>
- </div>
- <div class="gl-border-t gl-px-4 gl-pt-3 gl-pb-2">
- {{ getButtonLabel(rows, cols) }}
- </div>
- </gl-disclosure-dropdown>
+ <div class="gl-border-t gl-px-4 gl-pt-3 gl-pb-2">
+ {{ getButtonLabel(rows, cols) }}
+ </div>
+ </gl-disclosure-dropdown>
+ <gl-tooltip :target="toggleId" placement="top">{{ __('Insert table') }}</gl-tooltip>
+ </div>
</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
index 4a3dfe3656c..efd0926d7ed 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
@@ -1,20 +1,33 @@
<script>
import { debounce } from 'lodash';
+import { GlButton, GlTooltipDirective as GlTooltip, GlSprintf } from '@gitlab/ui';
import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
-import { __ } from '~/locale';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import SandboxedMermaid from '~/behaviors/components/sandboxed_mermaid.vue';
import codeBlockLanguageLoader from '../../services/code_block_language_loader';
import EditorStateObserver from '../editor_state_observer.vue';
+import { memoizedGet } from '../../services/utils';
+import {
+ lineOffsetToLangParams,
+ langParamsToLineOffset,
+ toAbsoluteLineOffset,
+ getLines,
+ appendNewlines,
+} from '../../services/code_suggestion_utils';
export default {
name: 'CodeBlock',
components: {
+ GlButton,
+ GlSprintf,
NodeViewWrapper,
NodeViewContent,
EditorStateObserver,
SandboxedMermaid,
},
+ directives: {
+ GlTooltip,
+ },
inject: ['contentEditor'],
props: {
editor: {
@@ -39,13 +52,54 @@ export default {
return {
diagramUrl: '',
diagramSource: '',
+
+ allLines: [],
+ deletedLines: [],
+ addedLines: [],
};
},
+ computed: {
+ isCodeSuggestion() {
+ return (
+ this.node.attrs.isCodeSuggestion &&
+ this.contentEditor.codeSuggestionsConfig?.canSuggest &&
+ this.contentEditor.codeSuggestionsConfig?.diffFile
+ );
+ },
+ classList() {
+ return this.isCodeSuggestion
+ ? 'gl-p-0! suggestion-added-input'
+ : `gl-p-3 code highlight ${this.$options.userColorScheme}`;
+ },
+ lineOffset() {
+ return langParamsToLineOffset(this.node.attrs.langParams);
+ },
+ absoluteLineOffset() {
+ if (!this.contentEditor.codeSuggestionsConfig) return [0, 0];
+
+ const { new_line: n } = this.contentEditor.codeSuggestionsConfig.line;
+ return toAbsoluteLineOffset(this.lineOffset, n);
+ },
+ disableDecrementLineStart() {
+ return this.absoluteLineOffset[0] <= 1;
+ },
+ disableIncrementLineStart() {
+ return this.lineOffset[0] >= 0;
+ },
+ disableDecrementLineEnd() {
+ return this.lineOffset[1] <= 0;
+ },
+ disableIncrementLineEnd() {
+ return this.absoluteLineOffset[1] >= this.allLines.length - 1;
+ },
+ },
async mounted() {
- this.updateDiagramPreview = debounce(
- this.updateDiagramPreview,
- DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
- );
+ if (this.isCodeSuggestion) {
+ await this.updateAllLines();
+ this.updateCodeSuggestion();
+ }
+
+ this.updateCodeBlock = debounce(this.updateCodeBlock, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
const lang = codeBlockLanguageLoader.findOrCreateLanguageBySyntax(this.node.attrs.language);
await codeBlockLanguageLoader.loadLanguage(lang.syntax);
@@ -53,7 +107,26 @@ export default {
this.updateAttributes({ language: this.node.attrs.language });
},
methods: {
- async updateDiagramPreview() {
+ async updateAllLines() {
+ const { diffFile } = this.contentEditor.codeSuggestionsConfig;
+ this.allLines = (await memoizedGet(diffFile.view_path.replace('/blob/', '/raw/'))).split(
+ '\n',
+ );
+ },
+ updateCodeSuggestion() {
+ this.deletedLines = appendNewlines(getLines(this.absoluteLineOffset, this.allLines));
+ this.addedLines = appendNewlines(
+ this.$refs.nodeViewContent?.$el.textContent.split('\n') || [],
+ );
+ },
+ updateNodeView() {
+ if (this.isCodeSuggestion) {
+ this.updateCodeSuggestion();
+ } else {
+ this.updateCodeBlock();
+ }
+ },
+ async updateCodeBlock() {
if (!this.node.attrs.showPreview) {
this.diagramSource = '';
return;
@@ -70,22 +143,34 @@ export default {
);
}
},
- },
- i18n: {
- frontmatter: __('frontmatter'),
+ updateLineOffset(deltaStart = 0, deltaEnd = 0) {
+ const { lineOffset } = this;
+
+ this.editor
+ .chain()
+ .updateAttributes('codeSuggestion', {
+ langParams: lineOffsetToLangParams([
+ lineOffset[0] + deltaStart,
+ lineOffset[1] + deltaEnd,
+ ]),
+ })
+ .run();
+ },
},
userColorScheme: gon.user_color_scheme,
};
</script>
<template>
- <editor-state-observer @transaction="updateDiagramPreview">
+ <editor-state-observer :debounce="0" @transaction="updateNodeView">
<node-view-wrapper
- :class="`content-editor-code-block gl-relative code highlight gl-p-3 ${$options.userColorScheme}`"
+ :class="classList"
+ class="content-editor-code-block gl-relative"
as="pre"
dir="auto"
>
<div
v-if="node.attrs.showPreview"
+ contenteditable="false"
class="gl-mt-n3! gl-ml-n4! gl-mr-n4! gl-mb-3 gl-bg-white! gl-p-4 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
>
<sandboxed-mermaid v-if="node.attrs.language === 'mermaid'" :source="diagramSource" />
@@ -93,12 +178,108 @@ export default {
</div>
<span
v-if="node.attrs.isFrontmatter"
+ contenteditable="false"
data-testid="frontmatter-label"
class="gl-absolute gl-top-0 gl-right-3"
+ >{{ __('frontmatter') }}:{{ node.attrs.language }}</span
+ >
+ <div
+ v-if="isCodeSuggestion"
contenteditable="false"
- >{{ $options.i18n.frontmatter }}:{{ node.attrs.language }}</span
+ class="gl-relative gl-z-index-0"
+ data-testid="code-suggestion-box"
>
- <node-view-content ref="nodeViewContent" as="code" />
+ <div
+ class="md-suggestion-header gl-flex-wrap gl-z-index-1 gl-w-full gl-border-none! gl-font-regular gl-px-4 gl-py-3 gl-border-b-1! gl-border-b-solid! gl-mr-n10!"
+ >
+ <div class="gl-font-weight-bold gl-pr-3">
+ {{ __('Suggested change') }}
+ </div>
+
+ <div
+ class="gl-display-flex gl-flex-wrap gl-align-items-center gl-pl-3 gl-gap-2 gl-white-space-nowrap"
+ >
+ <gl-sprintf :message="__('From line %{line1} to %{line2}')">
+ <template #line1>
+ <div class="gl-display-flex gl-bg-gray-50 gl-rounded-base gl-mx-1">
+ <gl-button
+ size="small"
+ icon="dash"
+ variant="confirm"
+ category="tertiary"
+ data-testid="decrement-line-start"
+ :aria-label="__('Decrement suggestion line start')"
+ :disabled="disableDecrementLineStart"
+ @click="updateLineOffset(-1, 0)"
+ />
+ <div
+ class="flex gl-align-items-center gl-justify-content-center gl-px-3 monospace"
+ >
+ <strong>{{ absoluteLineOffset[0] }}</strong>
+ </div>
+ <gl-button
+ size="small"
+ icon="plus"
+ variant="confirm"
+ category="tertiary"
+ data-testid="increment-line-start"
+ :aria-label="__('Increment suggestion line start')"
+ :disabled="disableIncrementLineStart"
+ @click="updateLineOffset(1, 0)"
+ />
+ </div>
+ </template>
+ <template #line2>
+ <div class="gl-display-flex gl-bg-gray-50 gl-rounded-base gl-ml-1">
+ <gl-button
+ size="small"
+ icon="dash"
+ variant="confirm"
+ category="tertiary"
+ data-testid="decrement-line-end"
+ :aria-label="__('Decrement suggestion line end')"
+ :disabled="disableDecrementLineEnd"
+ @click="updateLineOffset(0, -1)"
+ />
+ <div
+ class="flex gl-align-items-center gl-justify-content-center gl-px-3 monospace"
+ >
+ <strong>{{ absoluteLineOffset[1] }}</strong>
+ </div>
+ <gl-button
+ size="small"
+ icon="plus"
+ variant="confirm"
+ category="tertiary"
+ data-testid="increment-line-end"
+ :aria-label="__('Increment suggestion line end')"
+ :disabled="disableIncrementLineEnd"
+ @click="updateLineOffset(0, 1)"
+ />
+ </div>
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
+
+ <div class="suggestion-deleted" data-testid="suggestion-deleted">
+ <code
+ v-for="(line, i) in deletedLines"
+ :key="i"
+ :data-line-number="absoluteLineOffset[0] + i"
+ >{{ line }}</code
+ >
+ </div>
+ <div class="suggestion-added gl-absolute" data-testid="suggestion-added">
+ <code
+ v-for="(line, i) in addedLines"
+ :key="i"
+ :data-line-number="absoluteLineOffset[0] + i"
+ >{{ line }}</code
+ >
+ </div>
+ </div>
+ <node-view-content ref="nodeViewContent" as="code" class="gl-relative gl-z-index-1" />
</node-view-wrapper>
</editor-state-observer>
</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/image.vue b/app/assets/javascripts/content_editor/components/wrappers/image.vue
new file mode 100644
index 00000000000..0b80802d993
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/image.vue
@@ -0,0 +1,103 @@
+<script>
+import { NodeViewWrapper } from '@tiptap/vue-2';
+
+export default {
+ name: 'ImageWrapper',
+ components: {
+ NodeViewWrapper,
+ },
+ props: {
+ getPos: {
+ type: Function,
+ required: true,
+ },
+ editor: {
+ type: Object,
+ required: true,
+ },
+ node: {
+ type: Object,
+ required: true,
+ },
+ selected: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ dragData: {},
+ };
+ },
+ mounted() {
+ document.addEventListener('mousemove', this.onDrag);
+ document.addEventListener('mouseup', this.onDragEnd);
+ },
+ destroyed() {
+ document.removeEventListener('mousemove', this.onDrag);
+ document.removeEventListener('mouseup', this.onDragEnd);
+ },
+ methods: {
+ onDragStart(handle, event) {
+ this.dragData = {
+ handle,
+ startX: event.screenX,
+ startY: event.screenY,
+ width: this.$refs.image.width,
+ height: this.$refs.image.height,
+ };
+ },
+ onDrag(event) {
+ const { handle, startX, width, height } = this.dragData;
+ if (!handle) return;
+
+ const deltaX = event.screenX - startX;
+ const newWidth = handle.includes('w') ? width - deltaX : width + deltaX;
+ const newHeight = (height / width) * newWidth;
+
+ this.$refs.image.setAttribute('width', newWidth);
+ this.$refs.image.setAttribute('height', newHeight);
+ },
+ onDragEnd() {
+ const { handle } = this.dragData;
+ if (!handle) return;
+
+ this.dragData = {};
+
+ this.editor
+ .chain()
+ .focus()
+ .updateAttributes(this.node.type, {
+ width: this.$refs.image.width,
+ height: this.$refs.image.height,
+ })
+ .setNodeSelection(this.getPos())
+ .run();
+ },
+ },
+ resizeHandles: ['ne', 'nw', 'se', 'sw'],
+};
+</script>
+<template>
+ <node-view-wrapper as="span" class="gl-relative gl-display-inline-block">
+ <span
+ v-for="handle in $options.resizeHandles"
+ v-show="selected"
+ :key="handle"
+ class="image-resize"
+ :class="`image-resize-${handle}`"
+ :data-testid="`image-resize-${handle}`"
+ @mousedown="onDragStart(handle, $event)"
+ ></span>
+ <img
+ ref="image"
+ :src="node.attrs.src"
+ :alt="node.attrs.alt"
+ :title="node.attrs.title"
+ :width="node.attrs.width || 'auto'"
+ :height="node.attrs.height || 'auto'"
+ :class="{ 'ProseMirror-selectednode': selected }"
+ />
+ </node-view-wrapper>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/reference.vue b/app/assets/javascripts/content_editor/components/wrappers/reference.vue
index 2b4b9891c77..4ec477232d4 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/reference.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/reference.vue
@@ -3,11 +3,12 @@ import { NodeViewWrapper } from '@tiptap/vue-2';
import { GlLink } from '@gitlab/ui';
export default {
- name: 'DetailsWrapper',
+ name: 'ReferenceWrapper',
components: {
NodeViewWrapper,
GlLink,
},
+ inject: ['contentEditor'],
props: {
node: {
type: Object,
@@ -19,6 +20,11 @@ export default {
default: false,
},
},
+ data() {
+ return {
+ href: '#',
+ };
+ },
computed: {
text() {
return this.node.attrs.text;
@@ -33,6 +39,11 @@ export default {
return gon.current_username === this.text.substring(1);
},
},
+ async mounted() {
+ const text = this.node.attrs.originalText || this.node.attrs.text;
+ const { href } = await this.contentEditor.resolveReference(text);
+ this.href = href || '';
+ },
};
</script>
<template>
@@ -40,7 +51,7 @@ export default {
<span v-if="isCommand">{{ text }}</span>
<gl-link
v-else
- href="#"
+ :href="href"
tabindex="-1"
class="gfm gl-cursor-text"
:class="{
diff --git a/app/assets/javascripts/content_editor/extensions/code_suggestion.js b/app/assets/javascripts/content_editor/extensions/code_suggestion.js
new file mode 100644
index 00000000000..c70a96769fb
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/code_suggestion.js
@@ -0,0 +1,81 @@
+import { lowlight } from 'lowlight/lib/core';
+import { textblockTypeInputRule } from '@tiptap/core';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+import { memoizedGet } from '../services/utils';
+import CodeBlockHighlight from './code_block_highlight';
+
+const backtickInputRegex = /^```suggestion[\s\n]$/;
+
+export default CodeBlockHighlight.extend({
+ name: 'codeSuggestion',
+
+ isolating: true,
+
+ addOptions() {
+ return {
+ lowlight,
+ config: {},
+ };
+ },
+
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ language: {
+ default: 'suggestion',
+ },
+ isCodeSuggestion: {
+ default: true,
+ },
+ };
+ },
+
+ addCommands() {
+ const ext = this;
+
+ return {
+ insertCodeSuggestion: (attributes) => async ({ editor }) => {
+ // do not insert a new suggestion if already inside a suggestion
+ if (editor.isActive('codeSuggestion')) return false;
+
+ const rawPath = ext.options.config.diffFile.view_path.replace('/blob/', '/raw/');
+ const allLines = (await memoizedGet(rawPath)).split('\n');
+ const { line } = ext.options.config;
+ let { lines } = ext.options.config;
+
+ if (!lines.length) lines = [line];
+
+ const content = lines.map((l) => allLines[l.new_line - 1]).join('\n');
+ const lineNumbers = `-${lines.length - 1}+0`;
+
+ editor.commands.insertContent({
+ type: 'codeSuggestion',
+ attrs: { langParams: lineNumbers, ...attributes },
+ // empty strings are not allowed in text nodes
+ content: [{ type: 'text', text: content || ' ' }],
+ });
+
+ return true;
+ },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ tag: 'pre[lang="suggestion"]',
+ },
+ ];
+ },
+
+ addInputRules() {
+ return [
+ textblockTypeInputRule({
+ find: backtickInputRegex,
+ type: this.type,
+ getAttributes: () => ({ language: 'suggestion', langParams: '-0+0' }),
+ }),
+ ];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/comment.js b/app/assets/javascripts/content_editor/extensions/comment.js
deleted file mode 100644
index 8e247e552a3..00000000000
--- a/app/assets/javascripts/content_editor/extensions/comment.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import { Node, textblockTypeInputRule } from '@tiptap/core';
-
-export const commentInputRegex = /^<!--[\s\n]$/;
-
-export default Node.create({
- name: 'comment',
- content: 'text*',
- marks: '',
- group: 'block',
- code: true,
- isolating: true,
- defining: true,
-
- parseHTML() {
- return [
- {
- tag: 'comment',
- preserveWhitespace: 'full',
- getContent(element, schema) {
- const node = schema.node('paragraph', {}, [
- schema.text(
- element.textContent.replace(/&#x([0-9A-F]{2,4});/gi, (_, code) =>
- String.fromCharCode(parseInt(code, 16)),
- ) || ' ',
- ),
- ]);
- return node.content;
- },
- },
- ];
- },
-
- renderHTML() {
- return [
- 'pre',
- { class: 'gl-p-0 gl-border-0 gl-bg-transparent gl-text-gray-300' },
- ['span', { class: 'content-editor-comment' }, 0],
- ];
- },
-
- addInputRules() {
- return [
- textblockTypeInputRule({
- find: commentInputRegex,
- type: this.type,
- }),
- ];
- },
-});
diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/copy_paste.js
index db13438de5e..f484ce98e90 100644
--- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js
+++ b/app/assets/javascripts/content_editor/extensions/copy_paste.js
@@ -2,11 +2,13 @@ import OrderedMap from 'orderedmap';
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Schema, DOMParser as ProseMirrorDOMParser, DOMSerializer } from '@tiptap/pm/model';
+import { uniqueId } from 'lodash';
import { __ } from '~/locale';
import { VARIANT_DANGER } from '~/alert';
import createMarkdownDeserializer from '../services/gl_api_markdown_deserializer';
import { ALERT_EVENT, EXTENSION_PRIORITY_HIGHEST } from '../constants';
import CodeBlockHighlight from './code_block_highlight';
+import CodeSuggestion from './code_suggestion';
import Diagram from './diagram';
import Frontmatter from './frontmatter';
@@ -14,7 +16,12 @@ const TEXT_FORMAT = 'text/plain';
const GFM_FORMAT = 'text/x-gfm';
const HTML_FORMAT = 'text/html';
const VS_CODE_FORMAT = 'vscode-editor-data';
-const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatter.name];
+const CODE_BLOCK_NODE_TYPES = [
+ CodeBlockHighlight.name,
+ CodeSuggestion.name,
+ Diagram.name,
+ Frontmatter.name,
+];
function parseHTML(schema, html) {
const parser = new DOMParser();
@@ -24,8 +31,23 @@ function parseHTML(schema, html) {
return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body) };
}
+const findLoader = (editor, loaderId) => {
+ let position;
+
+ editor.view.state.doc.descendants((descendant, pos) => {
+ if (descendant.type.name === 'loading' && descendant.attrs.id === loaderId) {
+ position = pos;
+ return false;
+ }
+
+ return true;
+ });
+
+ return position;
+};
+
export default Extension.create({
- name: 'pasteMarkdown',
+ name: 'copyPaste',
priority: EXTENSION_PRIORITY_HIGHEST,
addOptions() {
return {
@@ -35,7 +57,7 @@ export default Extension.create({
},
addCommands() {
return {
- pasteContent: (content = '', processMarkdown = true) => async () => {
+ pasteContent: (content = '', processMarkdown = true) => () => {
const { editor, options } = this;
const { renderMarkdown, eventHub } = options;
const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
@@ -43,23 +65,37 @@ export default Extension.create({
const pasteSchemaSpec = { ...editor.schema.spec };
pasteSchemaSpec.marks = OrderedMap.from(pasteSchemaSpec.marks).remove('span');
pasteSchemaSpec.nodes = OrderedMap.from(pasteSchemaSpec.nodes).remove('div').remove('pre');
- const schema = new Schema(pasteSchemaSpec);
+ const pasteSchema = new Schema(pasteSchemaSpec);
const promise = processMarkdown
- ? deserializer.deserialize({ schema, markdown: content })
- : Promise.resolve(parseHTML(schema, content));
-
- promise
- .then(({ document }) => {
+ ? deserializer.deserialize({ schema: pasteSchema, markdown: content })
+ : Promise.resolve(parseHTML(pasteSchema, content));
+ const loaderId = uniqueId('loading');
+
+ Promise.resolve()
+ .then(() => {
+ editor.commands.insertContent({ type: 'loading', attrs: { id: loaderId } });
+ return promise;
+ })
+ .then(async ({ document }) => {
if (!document) return;
- const { firstChild } = document.content;
+ const pos = findLoader(editor, loaderId);
+ if (!pos) return;
+
+ const { firstChild, childCount } = document.content;
const toPaste =
- document.content.childCount === 1 && firstChild.type.name === 'paragraph'
+ childCount === 1 && firstChild.type.name === 'paragraph'
? firstChild.content
: document.content;
- editor.commands.insertContent(toPaste.toJSON());
+ editor
+ .chain()
+ .deleteRange({ from: pos, to: pos + 1 })
+ .insertContentAt(pos, toPaste.toJSON(), {
+ updateSelection: false,
+ })
+ .run();
})
.catch(() => {
eventHub.$emit(ALERT_EVENT, {
@@ -94,7 +130,7 @@ export default Extension.create({
return [
new Plugin({
- key: new PluginKey('pasteMarkdown'),
+ key: new PluginKey('copyPaste'),
props: {
handleDOMEvents: {
copy: handleCutAndCopy,
diff --git a/app/assets/javascripts/content_editor/extensions/hard_break.js b/app/assets/javascripts/content_editor/extensions/hard_break.js
index fb81c6b79b6..6d7ff92e64b 100644
--- a/app/assets/javascripts/content_editor/extensions/hard_break.js
+++ b/app/assets/javascripts/content_editor/extensions/hard_break.js
@@ -2,8 +2,6 @@ import { HardBreak } from '@tiptap/extension-hard-break';
export default HardBreak.extend({
addKeyboardShortcuts() {
- return {
- 'Shift-Enter': () => this.editor.commands.setHardBreak(),
- };
+ return {};
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js
index 58c16297886..d245b86543f 100644
--- a/app/assets/javascripts/content_editor/extensions/image.js
+++ b/app/assets/javascripts/content_editor/extensions/image.js
@@ -1,5 +1,7 @@
import { Image } from '@tiptap/extension-image';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
import { PARSE_HTML_PRIORITY_HIGH } from '../constants';
+import ImageWrapper from '../components/wrappers/image.vue';
const resolveImageEl = (element) =>
element.nodeName === 'IMG' ? element : element.querySelector('img');
@@ -97,4 +99,7 @@ export default Image.extend({
},
];
},
+ addNodeView() {
+ return VueNodeViewRenderer(ImageWrapper);
+ },
});
diff --git a/app/assets/javascripts/content_editor/extensions/loading.js b/app/assets/javascripts/content_editor/extensions/loading.js
new file mode 100644
index 00000000000..0115fb10d5d
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/loading.js
@@ -0,0 +1,23 @@
+import { Node } from '@tiptap/core';
+
+export default Node.create({
+ name: 'loading',
+ inline: true,
+ group: 'inline',
+
+ addAttributes() {
+ return {
+ id: {
+ default: null,
+ },
+ };
+ },
+
+ renderHTML() {
+ return [
+ 'span',
+ { class: 'gl-display-inline-flex gl-align-items-center' },
+ ['span', { class: 'gl-dots-loader gl-mx-2' }, ['span']],
+ ];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/paragraph.js b/app/assets/javascripts/content_editor/extensions/paragraph.js
index c63b64fd784..bddd8b38b06 100644
--- a/app/assets/javascripts/content_editor/extensions/paragraph.js
+++ b/app/assets/javascripts/content_editor/extensions/paragraph.js
@@ -9,4 +9,14 @@ export default Paragraph.extend({
},
};
},
+
+ addKeyboardShortcuts() {
+ return {
+ 'Shift-Enter': async () => {
+ // can only delegate one shortcut to another async
+ await Promise.resolve();
+ this.editor.commands.enter();
+ },
+ };
+ },
});
diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js
index ef69b9bbda6..fd248709b5a 100644
--- a/app/assets/javascripts/content_editor/extensions/reference.js
+++ b/app/assets/javascripts/content_editor/extensions/reference.js
@@ -63,6 +63,12 @@ export default Node.create({
};
},
+ addCommands() {
+ return {
+ insertQuickAction: () => ({ commands }) => commands.insertContent('<p>/</p>'),
+ };
+ },
+
addInputRules() {
const { editor } = this;
const { assetResolver } = this.options;
diff --git a/app/assets/javascripts/content_editor/services/code_suggestion_utils.js b/app/assets/javascripts/content_editor/services/code_suggestion_utils.js
new file mode 100644
index 00000000000..836729790ae
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/code_suggestion_utils.js
@@ -0,0 +1,32 @@
+export function langParamsToLineOffset(langParams) {
+ if (!langParams) return [0, 0];
+ const match = langParams.match(/([-+]\d+)([-+]\d+)/);
+ return match ? [parseInt(match[1], 10), parseInt(match[2], 10)] : [0, 0];
+}
+
+export function lineOffsetToLangParams(lineOffset) {
+ let langParams = '';
+ langParams += lineOffset[0] <= 0 ? `-${-lineOffset[0]}` : `+${lineOffset[0]}`;
+ langParams += lineOffset[1] < 0 ? lineOffset[1] : `+${lineOffset[1]}`;
+ return langParams;
+}
+
+export function toAbsoluteLineOffset(lineOffset, lineNumber) {
+ return [lineOffset[0] + lineNumber, lineOffset[1] + lineNumber];
+}
+
+export function getLines(absoluteLineOffset, allLines) {
+ return allLines.slice(absoluteLineOffset[0] - 1, absoluteLineOffset[1]);
+}
+
+// \u200b is a zero width space character (Alternatively &ZeroWidthSpace;, &#8203; or &#x200B;).
+// Due to the nature of HTML, if you have a blank line in the deleted/inserted code, it would
+// render with 0 height. (Each line is in its <code> element.) This means that blank lines
+// would be skipped when rendering the diff.
+// We append this character to the end of each line to make sure that the line is not empty
+// and the line numbers are rendered correctly.
+const ZERO_WIDTH_SPACE = '\u200b';
+
+export function appendNewlines(lines) {
+ return lines.map((l, i, arr) => `${l}${ZERO_WIDTH_SPACE}${i === arr.length - 1 ? '' : '\n'}`);
+}
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index ec0f2f028d9..bc1ee696323 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -1,6 +1,14 @@
/* eslint-disable no-underscore-dangle */
export class ContentEditor {
- constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub, drawioEnabled }) {
+ constructor({
+ tiptapEditor,
+ serializer,
+ deserializer,
+ assetResolver,
+ eventHub,
+ drawioEnabled,
+ codeSuggestionsConfig,
+ }) {
this._tiptapEditor = tiptapEditor;
this._serializer = serializer;
this._deserializer = deserializer;
@@ -8,9 +16,13 @@ export class ContentEditor {
this._assetResolver = assetResolver;
this._pristineDoc = null;
+ this.codeSuggestionsConfig = codeSuggestionsConfig;
this.drawioEnabled = drawioEnabled;
}
+ /**
+ * @type {import('@tiptap/core').Editor}
+ */
get tiptapEditor() {
return this._tiptapEditor;
}
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index ee1f706ec7e..51e41ceefaf 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -9,8 +9,9 @@ import Bold from '../extensions/bold';
import BulletList from '../extensions/bullet_list';
import Code from '../extensions/code';
import CodeBlockHighlight from '../extensions/code_block_highlight';
+import CodeSuggestion from '../extensions/code_suggestion';
import ColorChip from '../extensions/color_chip';
-import Comment from '../extensions/comment';
+import CopyPaste from '../extensions/copy_paste';
import DescriptionItem from '../extensions/description_item';
import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
@@ -40,10 +41,10 @@ import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
import Link from '../extensions/link';
import ListItem from '../extensions/list_item';
+import Loading from '../extensions/loading';
import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
-import PasteMarkdown from '../extensions/paste_markdown';
import Reference from '../extensions/reference';
import ReferenceLabel from '../extensions/reference_label';
import ReferenceDefinition from '../extensions/reference_definition';
@@ -73,11 +74,6 @@ import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
new Editor({
extensions: [...extensions],
- editorProps: {
- attributes: {
- class: 'gl-shadow-none!',
- },
- },
...options,
});
@@ -90,6 +86,7 @@ export const createContentEditor = ({
drawioEnabled = false,
enableAutocomplete,
autocompleteDataSources = {},
+ codeSuggestionsConfig = {},
} = {}) => {
if (!isFunction(renderMarkdown)) {
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
@@ -112,8 +109,8 @@ export const createContentEditor = ({
BulletList,
Code,
ColorChip,
- Comment,
CodeBlockHighlight,
+ CodeSuggestion.configure({ config: codeSuggestionsConfig }),
DescriptionItem,
DescriptionList,
Details,
@@ -142,10 +139,11 @@ export const createContentEditor = ({
ExternalKeydownHandler.configure({ eventHub }),
Link,
ListItem,
+ Loading,
MathInline,
OrderedList,
Paragraph,
- PasteMarkdown.configure({ eventHub, renderMarkdown, serializer }),
+ CopyPaste.configure({ eventHub, renderMarkdown, serializer }),
Reference.configure({ assetResolver }),
ReferenceLabel,
ReferenceDefinition,
@@ -181,5 +179,6 @@ export const createContentEditor = ({
deserializer,
assetResolver,
drawioEnabled,
+ codeSuggestionsConfig,
});
};
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index 4dbafd1632d..972b4acf523 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -8,12 +8,12 @@ import Bold from '../extensions/bold';
import BulletList from '../extensions/bullet_list';
import Code from '../extensions/code';
import CodeBlockHighlight from '../extensions/code_block_highlight';
+import CodeSuggestion from '../extensions/code_suggestion';
import DescriptionItem from '../extensions/description_item';
import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
import DrawioDiagram from '../extensions/drawio_diagram';
-import Comment from '../extensions/comment';
import Diagram from '../extensions/diagram';
import Emoji from '../extensions/emoji';
import Figure from '../extensions/figure';
@@ -32,6 +32,7 @@ import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
import Link from '../extensions/link';
import ListItem from '../extensions/list_item';
+import Loading from '../extensions/loading';
import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
@@ -52,7 +53,6 @@ import Text from '../extensions/text';
import Video from '../extensions/video';
import WordBreak from '../extensions/word_break';
import {
- renderComment,
renderCodeBlock,
renderHardBreak,
renderTable,
@@ -134,8 +134,8 @@ const defaultSerializerConfig = {
}),
[BulletList.name]: preserveUnchanged(renderBulletList),
[CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock),
- [Comment.name]: renderComment,
[Diagram.name]: preserveUnchanged(renderCodeBlock),
+ [CodeSuggestion.name]: preserveUnchanged(renderCodeBlock),
[DrawioDiagram.name]: preserveUnchanged({
render: renderImage,
inline: true,
@@ -195,6 +195,7 @@ const defaultSerializerConfig = {
inline: true,
}),
[ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item),
+ [Loading.name]: () => {},
[OrderedList.name]: preserveUnchanged(renderOrderedList),
[Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph),
[Reference.name]: renderReference,
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index b2cbc9c3fed..17e650644b3 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -365,13 +365,6 @@ export function renderPlayable(state, node) {
renderImage(state, node);
}
-export function renderComment(state, node) {
- state.write('<!--');
- state.write(node.textContent);
- state.write('-->');
- state.closeBlock(node);
-}
-
export function renderCodeBlock(state, node) {
state.write(
`\`\`\`${
diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js
index 1c128b4aa19..391d3b1a665 100644
--- a/app/assets/javascripts/content_editor/services/utils.js
+++ b/app/assets/javascripts/content_editor/services/utils.js
@@ -1,3 +1,6 @@
+import axios from 'axios';
+import { memoize } from 'lodash';
+
export const hasSelection = (tiptapEditor) => {
const { from, to } = tiptapEditor.state.selection;
@@ -5,3 +8,8 @@ export const hasSelection = (tiptapEditor) => {
};
export const clamp = (n, min, max) => Math.max(Math.min(n, max), min);
+
+export const memoizedGet = memoize(async (path) => {
+ const { data } = await axios(path, { responseType: 'blob' });
+ return data.text();
+});
diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_approved.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_approved.vue
index a7787ae84bc..9f166e594b8 100644
--- a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_approved.vue
+++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_approved.vue
@@ -1,8 +1,5 @@
<script>
-import { GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
-import TargetLink from '../target_link.vue';
-import ResourceParentLink from '../resource_parent_link.vue';
import ContributionEventBase from './contribution_event_base.vue';
export default {
@@ -12,7 +9,7 @@ export default {
'ContributionEvent|Approved merge request %{targetLink} in %{resourceParentLink}.',
),
},
- components: { ContributionEventBase, GlSprintf, TargetLink, ResourceParentLink },
+ components: { ContributionEventBase },
props: {
/**
* Expected format
@@ -52,14 +49,10 @@ export default {
</script>
<template>
- <contribution-event-base :event="event" icon-name="approval-solid" icon-class="gl-text-green-500">
- <gl-sprintf :message="$options.i18n.message">
- <template #targetLink>
- <target-link :event="event" />
- </template>
- <template #resourceParentLink>
- <resource-parent-link :event="event" />
- </template>
- </gl-sprintf>
- </contribution-event-base>
+ <contribution-event-base
+ :event="event"
+ :message="$options.i18n.message"
+ icon-name="approval-solid"
+ icon-class="gl-text-green-500"
+ />
</template>
diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue
index 93ac94a6f4f..e3d3360cd0c 100644
--- a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue
+++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue
@@ -1,9 +1,19 @@
<script>
-import { GlAvatarLabeled, GlAvatarLink, GlIcon } from '@gitlab/ui';
+import { GlAvatarLabeled, GlAvatarLink, GlIcon, GlSprintf } from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import TargetLink from '../target_link.vue';
+import ResourceParentLink from '../resource_parent_link.vue';
export default {
- components: { GlAvatarLabeled, GlAvatarLink, GlIcon, TimeAgoTooltip },
+ components: {
+ GlAvatarLabeled,
+ GlAvatarLink,
+ GlIcon,
+ GlSprintf,
+ TimeAgoTooltip,
+ TargetLink,
+ ResourceParentLink,
+ },
props: {
event: {
type: Object,
@@ -13,6 +23,11 @@ export default {
type: String,
required: true,
},
+ message: {
+ type: String,
+ required: false,
+ default: '',
+ },
iconClass: {
type: String,
required: false,
@@ -44,7 +59,15 @@ export default {
<div class="gl-pl-8 gl-mt-2" data-testid="event-body">
<div class="gl-text-secondary">
<gl-icon :class="iconClass" :name="iconName" />
- <slot></slot>
+ <gl-sprintf v-if="message" :message="message">
+ <template #targetLink>
+ <target-link :event="event" />
+ </template>
+ <template #resourceParentLink>
+ <resource-parent-link :event="event" />
+ </template>
+ </gl-sprintf>
+ <slot v-else></slot>
</div>
<div v-if="$scopedSlots['additional-info']" class="gl-mt-2">
<slot name="additional-info"></slot>
diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_expired.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_expired.vue
new file mode 100644
index 00000000000..8daccd66aeb
--- /dev/null
+++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_expired.vue
@@ -0,0 +1,46 @@
+<script>
+import { s__ } from '~/locale';
+import ContributionEventBase from './contribution_event_base.vue';
+
+export default {
+ name: 'ContributionEventExpired',
+ i18n: {
+ message: s__(
+ 'ContributionEvent|Removed due to membership expiration from %{resourceParentLink}.',
+ ),
+ },
+ components: { ContributionEventBase },
+ props: {
+ /**
+ * Expected format
+ * {
+ * created_at: string;
+ * action: "expired"
+ * author: {
+ * id: number;
+ * username: string;
+ * name: string;
+ * state: string;
+ * avatar_url: string;
+ * web_url: string;
+ * };
+ * resource_parent: {
+ * type: "project";
+ * full_name: string;
+ * full_path: string;
+ * web_url: string;
+ * avatar_url: string;
+ * };
+ * };
+ */
+ event: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <contribution-event-base :event="event" :message="$options.i18n.message" icon-name="expire" />
+</template>
diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_joined.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_joined.vue
new file mode 100644
index 00000000000..1b60582e7e1
--- /dev/null
+++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_joined.vue
@@ -0,0 +1,44 @@
+<script>
+import { s__ } from '~/locale';
+import ContributionEventBase from './contribution_event_base.vue';
+
+export default {
+ name: 'ContributionEventJoined',
+ i18n: {
+ message: s__('ContributionEvent|Joined project %{resourceParentLink}.'),
+ },
+ components: { ContributionEventBase },
+ props: {
+ /**
+ * Expected format
+ * {
+ * created_at: string;
+ * action: "joined"
+ * author: {
+ * id: number;
+ * username: string;
+ * name: string;
+ * state: string;
+ * avatar_url: string;
+ * web_url: string;
+ * };
+ * resource_parent: {
+ * type: "project";
+ * full_name: string;
+ * full_path: string;
+ * web_url: string;
+ * avatar_url: string;
+ * };
+ * };
+ */
+ event: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <contribution-event-base :event="event" :message="$options.i18n.message" icon-name="users" />
+</template>
diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_left.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_left.vue
new file mode 100644
index 00000000000..701126b4a74
--- /dev/null
+++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_left.vue
@@ -0,0 +1,44 @@
+<script>
+import { s__ } from '~/locale';
+import ContributionEventBase from './contribution_event_base.vue';
+
+export default {
+ name: 'ContributionEventLeft',
+ i18n: {
+ message: s__('ContributionEvent|Left project %{resourceParentLink}.'),
+ },
+ components: { ContributionEventBase },
+ props: {
+ /**
+ * Expected format
+ * {
+ * created_at: string;
+ * action: "left"
+ * author: {
+ * id: number;
+ * username: string;
+ * name: string;
+ * state: string;
+ * avatar_url: string;
+ * web_url: string;
+ * };
+ * resource_parent: {
+ * type: "project";
+ * full_name: string;
+ * full_path: string;
+ * web_url: string;
+ * avatar_url: string;
+ * };
+ * };
+ */
+ event: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <contribution-event-base :event="event" :message="$options.i18n.message" icon-name="leave" />
+</template>
diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_merged.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_merged.vue
new file mode 100644
index 00000000000..d2566160b18
--- /dev/null
+++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_merged.vue
@@ -0,0 +1,29 @@
+<script>
+import { s__ } from '~/locale';
+import ContributionEventBase from './contribution_event_base.vue';
+
+export default {
+ name: 'ContributionEventMerged',
+ i18n: {
+ message: s__(
+ 'ContributionEvent|Accepted merge request %{targetLink} in %{resourceParentLink}.',
+ ),
+ },
+ components: { ContributionEventBase },
+ props: {
+ event: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <contribution-event-base
+ :event="event"
+ :message="$options.i18n.message"
+ icon-name="git-merge"
+ icon-class="gl-text-blue-600"
+ />
+</template>
diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_private.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_private.vue
new file mode 100644
index 00000000000..ba9bc25e310
--- /dev/null
+++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_private.vue
@@ -0,0 +1,24 @@
+<script>
+import { s__ } from '~/locale';
+import ContributionEventBase from './contribution_event_base.vue';
+
+export default {
+ name: 'ContributionEventPrivate',
+ i18n: {
+ message: s__('ContributionEvent|Made a private contribution.'),
+ },
+ components: { ContributionEventBase },
+ props: {
+ event: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <contribution-event-base :event="event" icon-name="eye-slash">{{
+ $options.i18n.message
+ }}</contribution-event-base>
+</template>
diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_pushed.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_pushed.vue
new file mode 100644
index 00000000000..557f2912f17
--- /dev/null
+++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_pushed.vue
@@ -0,0 +1,110 @@
+<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { PUSH_EVENT_REF_TYPE_BRANCH, PUSH_EVENT_REF_TYPE_TAG } from '../../constants';
+import ResourceParentLink from '../resource_parent_link.vue';
+import ContributionEventBase from './contribution_event_base.vue';
+
+export default {
+ name: 'ContributionEventPushed',
+ i18n: {
+ new: {
+ [PUSH_EVENT_REF_TYPE_BRANCH]: s__(
+ 'ContributionEvent|Pushed a new branch %{refLink} in %{resourceParentLink}.',
+ ),
+ [PUSH_EVENT_REF_TYPE_TAG]: s__(
+ 'ContributionEvent|Pushed a new tag %{refLink} in %{resourceParentLink}.',
+ ),
+ },
+ removed: {
+ [PUSH_EVENT_REF_TYPE_BRANCH]: s__(
+ 'ContributionEvent|Deleted branch %{refLink} in %{resourceParentLink}.',
+ ),
+ [PUSH_EVENT_REF_TYPE_TAG]: s__(
+ 'ContributionEvent|Deleted tag %{refLink} in %{resourceParentLink}.',
+ ),
+ },
+ pushed: {
+ [PUSH_EVENT_REF_TYPE_BRANCH]: s__(
+ 'ContributionEvent|Pushed to branch %{refLink} in %{resourceParentLink}.',
+ ),
+ [PUSH_EVENT_REF_TYPE_TAG]: s__(
+ 'ContributionEvent|Pushed to tag %{refLink} in %{resourceParentLink}.',
+ ),
+ },
+ multipleCommits: s__(
+ 'ContributionEvent|…and %{count} more commits. %{linkStart}Compare%{linkEnd}.',
+ ),
+ },
+ components: { ContributionEventBase, GlSprintf, GlLink, ResourceParentLink },
+ props: {
+ event: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ref() {
+ return this.event.ref;
+ },
+ commit() {
+ return this.event.commit;
+ },
+ message() {
+ if (this.ref.is_new) {
+ return this.$options.i18n.new[this.ref.type];
+ } else if (this.ref.is_removed) {
+ return this.$options.i18n.removed[this.ref.type];
+ }
+
+ return this.$options.i18n.pushed[this.ref.type];
+ },
+ iconName() {
+ if (this.ref.is_removed) {
+ return 'remove';
+ }
+
+ return 'commit';
+ },
+ hasMultipleCommits() {
+ return this.commit.count > 1;
+ },
+ },
+};
+</script>
+
+<template>
+ <contribution-event-base :event="event" :icon-name="iconName">
+ <gl-sprintf :message="message">
+ <template #refLink>
+ <gl-link v-if="ref.path" :href="ref.path" class="gl-font-monospace">{{ ref.name }}</gl-link>
+ <span v-else class="gl-font-monospace">{{ ref.name }}</span>
+ </template>
+ <template #resourceParentLink>
+ <resource-parent-link :event="event" />
+ </template>
+ </gl-sprintf>
+ <template v-if="!ref.is_removed" #additional-info>
+ <div>
+ <gl-link :href="commit.path" class="gl-font-monospace">{{ commit.truncated_sha }}</gl-link>
+ <template v-if="commit.title">
+ &middot;
+ <span>{{ commit.title }}</span>
+ </template>
+ </div>
+ <div v-if="hasMultipleCommits" class="gl-mt-2">
+ <gl-sprintf :message="$options.i18n.multipleCommits">
+ <template #count>{{ commit.count - 1 }}</template>
+ <template #link="{ content }">
+ <gl-link :href="commit.compare_path"
+ >{{ content }}
+ <span class="gl-font-monospace"
+ >{{ commit.from_truncated_sha }}…{{ commit.to_truncated_sha }}</span
+ ></gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </div>
+ </template>
+ </contribution-event-base>
+</template>
diff --git a/app/assets/javascripts/contribution_events/components/contribution_events.vue b/app/assets/javascripts/contribution_events/components/contribution_events.vue
index 41ec4f5692e..62c803b9217 100644
--- a/app/assets/javascripts/contribution_events/components/contribution_events.vue
+++ b/app/assets/javascripts/contribution_events/components/contribution_events.vue
@@ -1,7 +1,21 @@
<script>
import EmptyComponent from '~/vue_shared/components/empty_component';
-import { EVENT_TYPE_APPROVED } from '../constants';
+import {
+ EVENT_TYPE_APPROVED,
+ EVENT_TYPE_EXPIRED,
+ EVENT_TYPE_JOINED,
+ EVENT_TYPE_LEFT,
+ EVENT_TYPE_PUSHED,
+ EVENT_TYPE_PRIVATE,
+ EVENT_TYPE_MERGED,
+} from '../constants';
import ContributionEventApproved from './contribution_event/contribution_event_approved.vue';
+import ContributionEventExpired from './contribution_event/contribution_event_expired.vue';
+import ContributionEventJoined from './contribution_event/contribution_event_joined.vue';
+import ContributionEventLeft from './contribution_event/contribution_event_left.vue';
+import ContributionEventPushed from './contribution_event/contribution_event_pushed.vue';
+import ContributionEventPrivate from './contribution_event/contribution_event_private.vue';
+import ContributionEventMerged from './contribution_event/contribution_event_merged.vue';
export default {
props: {
@@ -99,6 +113,24 @@ export default {
case EVENT_TYPE_APPROVED:
return ContributionEventApproved;
+ case EVENT_TYPE_EXPIRED:
+ return ContributionEventExpired;
+
+ case EVENT_TYPE_JOINED:
+ return ContributionEventJoined;
+
+ case EVENT_TYPE_LEFT:
+ return ContributionEventLeft;
+
+ case EVENT_TYPE_PUSHED:
+ return ContributionEventPushed;
+
+ case EVENT_TYPE_PRIVATE:
+ return ContributionEventPrivate;
+
+ case EVENT_TYPE_MERGED:
+ return ContributionEventMerged;
+
default:
return EmptyComponent;
}
diff --git a/app/assets/javascripts/contribution_events/components/resource_parent_link.vue b/app/assets/javascripts/contribution_events/components/resource_parent_link.vue
index 5add9d788bb..dd7b20ac794 100644
--- a/app/assets/javascripts/contribution_events/components/resource_parent_link.vue
+++ b/app/assets/javascripts/contribution_events/components/resource_parent_link.vue
@@ -18,5 +18,7 @@ export default {
</script>
<template>
- <gl-link :href="resourceParent.web_url">{{ resourceParent.full_name }}</gl-link>
+ <gl-link v-if="resourceParent" :href="resourceParent.web_url">{{
+ resourceParent.full_name
+ }}</gl-link>
</template>
diff --git a/app/assets/javascripts/contribution_events/components/target_link.vue b/app/assets/javascripts/contribution_events/components/target_link.vue
index a661121b2fb..6559d6c7272 100644
--- a/app/assets/javascripts/contribution_events/components/target_link.vue
+++ b/app/assets/javascripts/contribution_events/components/target_link.vue
@@ -27,5 +27,5 @@ export default {
</script>
<template>
- <gl-link v-bind="targetLinkAttributes">{{ targetLinkText }}</gl-link>
+ <gl-link v-if="target" v-bind="targetLinkAttributes">{{ targetLinkText }}</gl-link>
</template>
diff --git a/app/assets/javascripts/contribution_events/constants.js b/app/assets/javascripts/contribution_events/constants.js
index 05f968e7bc4..d4444e3bede 100644
--- a/app/assets/javascripts/contribution_events/constants.js
+++ b/app/assets/javascripts/contribution_events/constants.js
@@ -12,3 +12,7 @@ export const EVENT_TYPE_DESTROYED = 'destroyed';
export const EVENT_TYPE_EXPIRED = 'expired';
export const EVENT_TYPE_APPROVED = 'approved';
export const EVENT_TYPE_PRIVATE = 'private';
+
+// From app/models/push_event_payload.rb#L22
+export const PUSH_EVENT_REF_TYPE_BRANCH = 'branch';
+export const PUSH_EVENT_REF_TYPE_TAG = 'tag';
diff --git a/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue b/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue
index ccd22085470..78d0a9da79a 100644
--- a/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue
+++ b/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue
@@ -76,7 +76,7 @@ export default {
@formValidation="formValidation"
/>
<div class="form-actions">
- <gl-button variant="success" category="primary" :disabled="!formIsValid" @click="submit">
+ <gl-button variant="confirm" category="primary" :disabled="!formIsValid" @click="submit">
{{ saveButtonText }}
</gl-button>
<gl-button class="float-right" :href="editIntegrationPath">{{ __('Cancel') }}</gl-button>
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 c49ab1ac43c..7ec3ec3f84d 100644
--- a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue
+++ b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue
@@ -225,7 +225,7 @@ export default {
</div>
</div>
<h5>{{ $options.translations.addTokenHeader }}</h5>
- <p class="profile-settings-content">
+ <p>
<gl-sprintf
:message="$options.translations.addTokenDescription"
:placeholders="placeholders.link"
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index 08177cd0eac..6dbf12054cf 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -515,9 +515,8 @@ export default class Notes {
}
if (discussionContainer.length === 0) {
if (noteEntity.diff_discussion_html) {
- const discussionElement = document.createElement('table');
+ let discussionElement = document.createElement('table');
let internalNote;
- let discussionDOM;
if (!noteEntity.on_image) {
/*
@@ -536,16 +535,15 @@ export default class Notes {
Curiously, DOMPurify **ADDS** a totally novel <tbody>, so we're actually
inserting a completely as-yet-unseen <tbody> element here.
*/
- discussionDOM = internalNote.querySelector('table').firstChild;
+ discussionElement = internalNote.querySelector('table').querySelector('.notes_holder');
} else {
// Image comments don't need <table> manipulation, they're already <div>s
internalNote = sanitize(noteEntity.diff_discussion_html, {
RETURN_DOM: true,
});
- discussionDOM = internalNote.firstChild;
+ discussionElement.insertAdjacentElement('afterbegin', internalNote.firstElementChild);
}
- discussionElement.insertAdjacentElement('afterbegin', discussionDOM);
renderGFM(discussionElement);
const $discussion = $(discussionElement).unwrap();
@@ -1464,7 +1462,11 @@ export default class Notes {
$note.addClass('fade-in-full');
renderGFM(Notes.getNodeToRender($note));
- $notesList.append($note);
+ if ($notesList.find('.discussion-reply-holder').length) {
+ $notesList.children('.timeline-entry').last().after($note);
+ } else {
+ $notesList.append($note);
+ }
return $note;
}
diff --git a/app/assets/javascripts/design_management/components/design_description/description_form.vue b/app/assets/javascripts/design_management/components/design_description/description_form.vue
index 890d7f80f8d..413442074f0 100644
--- a/app/assets/javascripts/design_management/components/design_description/description_form.vue
+++ b/app/assets/javascripts/design_management/components/design_description/description_form.vue
@@ -1,6 +1,5 @@
<script>
import { GlButton, GlFormGroup, GlAlert, GlTooltipDirective } from '@gitlab/ui';
-
import SafeHtml from '~/vue_shared/directives/safe_html';
import { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
@@ -9,7 +8,7 @@ import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import { toggleMarkCheckboxes } from '~/behaviors/markdown/utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-
+import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking';
import updateDesignDescriptionMutation from '../../graphql/mutations/update_design_description.mutation.graphql';
import { UPDATE_DESCRIPTION_ERROR } from '../../utils/error_messages';
@@ -110,6 +109,11 @@ export default {
async updateDesignDescription() {
this.isSubmitting = true;
+ if (this.$refs.markdownEditor) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ trackSavedUsingEditor(this.$refs.markdownEditor.isContentEditorActive, 'Design');
+ }
+
try {
const designDescriptionInput = { description: this.descriptionText, id: this.design.id };
@@ -165,6 +169,7 @@ export default {
</gl-alert>
</div>
<markdown-editor
+ ref="markdownEditor"
:value="descriptionText"
:render-markdown-path="markdownPreviewPath"
:markdown-docs-path="$options.markdownDocsPath"
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
index 5affd448419..45f33967476 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -292,7 +292,9 @@ export default {
<design-note-pin :is-resolved="discussion.resolved" :label="discussion.index" />
<ul
class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none"
+ :class="{ 'gl-bg-blue-50': isDiscussionActive }"
data-qa-selector="design_discussion_content"
+ data-testid="design-discussion-content"
>
<design-note
:note="firstNote"
@@ -300,7 +302,7 @@ export default {
:is-resolving="isResolving"
:is-discussion="true"
:noteable-id="noteableId"
- :class="{ 'gl-bg-blue-50': isDiscussionActive }"
+ :design-variables="designVariables"
@delete-note="showDeleteNoteConfirmationModal($event)"
>
<template v-if="isLoggedIn && discussion.resolvable" #resolve-discussion>
@@ -343,7 +345,7 @@ export default {
:is-resolving="isResolving"
:noteable-id="noteableId"
:is-discussion="false"
- :class="{ 'gl-bg-blue-50': isDiscussionActive }"
+ :design-variables="designVariables"
@delete-note="showDeleteNoteConfirmationModal($event)"
/>
<li
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
index 0eac2cad68d..1f2c9f19a95 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
@@ -7,14 +7,21 @@ import {
GlLink,
GlTooltipDirective,
} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { produce } from 'immer';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPENAME_USER } from '~/graphql_shared/constants';
import { __ } from '~/locale';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import EmojiPicker from '~/emoji/components/picker.vue';
+import getDesignQuery from '../../graphql/queries/get_design.query.graphql';
import updateNoteMutation from '../../graphql/mutations/update_note.mutation.graphql';
+import designNoteAwardEmojiToggleMutation from '../../graphql/mutations/design_note_award_emoji_toggle.mutation.graphql';
import { hasErrors } from '../../utils/cache_update';
import { findNoteId, extractDesignNoteId } from '../../utils/design_management_utils';
+import DesignNoteAwardsList from './design_note_awards_list.vue';
import DesignReplyForm from './design_reply_form.vue';
export default {
@@ -24,7 +31,9 @@ export default {
deleteCommentText: __('Delete comment'),
},
components: {
+ DesignNoteAwardsList,
DesignReplyForm,
+ EmojiPicker,
GlAvatar,
GlAvatarLink,
GlButton,
@@ -37,6 +46,7 @@ export default {
GlTooltip: GlTooltipDirective,
SafeHtml,
},
+ inject: ['issueIid', 'projectPath'],
props: {
note: {
type: Object,
@@ -56,6 +66,10 @@ export default {
type: String,
required: true,
},
+ designVariables: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -64,6 +78,26 @@ export default {
};
},
computed: {
+ currentUserId() {
+ return window.gon.current_user_id;
+ },
+ currentUserFullName() {
+ return window.gon.current_user_fullname;
+ },
+ canAwardEmoji() {
+ return this.note.userPermissions.awardEmoji;
+ },
+ awards() {
+ return this.note.awardEmoji.nodes.map((award) => {
+ return {
+ ...award,
+ user: {
+ ...award.user,
+ id: getIdFromGraphQLId(award.user.id),
+ },
+ };
+ });
+ },
author() {
return this.note.author;
},
@@ -124,6 +158,93 @@ export default {
this.$emit('error', data.errors[0]);
}
},
+ isEmojiPresentForCurrentUser(name) {
+ return (
+ this.awards.findIndex(
+ (emoji) => emoji.name === name && emoji.user.id === this.currentUserId,
+ ) > -1
+ );
+ },
+ /**
+ * Prepare award emoji nodes based on emoji name
+ * and whether the user has toggled the emoji off or on
+ */
+ getAwardEmojiNodes(name, toggledOn) {
+ // If the emoji toggled on, add the emoji
+ if (toggledOn) {
+ // If emoji is already present in award list, no action is needed
+ if (this.isEmojiPresentForCurrentUser(name)) {
+ return this.note.awardEmoji.nodes;
+ }
+
+ // else make a copy of unmutable list and return the list after adding the new emoji
+ const awardEmojiNodes = [...this.note.awardEmoji.nodes];
+ awardEmojiNodes.push({
+ name,
+ __typename: 'AwardEmoji',
+ user: {
+ id: convertToGraphQLId(TYPENAME_USER, this.currentUserId),
+ name: this.currentUserFullName,
+ __typename: 'UserCore',
+ },
+ });
+
+ return awardEmojiNodes;
+ }
+
+ // else just filter the emoji
+ return this.note.awardEmoji.nodes.filter(
+ (emoji) =>
+ !(emoji.name === name && getIdFromGraphQLId(emoji.user.id) === this.currentUserId),
+ );
+ },
+ handleAwardEmoji(name) {
+ this.$apollo
+ .mutate({
+ mutation: designNoteAwardEmojiToggleMutation,
+ variables: {
+ name,
+ awardableId: this.note.id,
+ },
+ optimisticResponse: {
+ awardEmojiToggle: {
+ errors: [],
+ toggledOn: !this.isEmojiPresentForCurrentUser(name),
+ },
+ },
+ update: (
+ cache,
+ {
+ data: {
+ awardEmojiToggle: { toggledOn },
+ },
+ },
+ ) => {
+ const query = {
+ query: getDesignQuery,
+ variables: this.designVariables,
+ };
+
+ const sourceData = cache.readQuery(query);
+
+ const newData = produce(sourceData, (draftState) => {
+ const {
+ awardEmoji,
+ } = draftState.project.issue.designCollection.designs.nodes[0].discussions.nodes
+ .find((d) => d.id === this.note.discussion.id)
+ .notes.nodes.find((n) => n.id === this.note.id);
+
+ awardEmoji.nodes = this.getAwardEmojiNodes(name, toggledOn);
+ });
+
+ cache.writeQuery({ ...query, data: newData });
+ },
+ })
+ .catch((error) => {
+ Sentry.captureException(error);
+ this.$emit('error', error);
+ });
+ },
},
updateNoteMutation,
};
@@ -131,7 +252,12 @@ export default {
<template>
<timeline-entry-item :id="`note_${noteAnchorId}`" class="design-note note-form">
- <gl-avatar-link :href="author.webUrl" class="gl-float-left gl-mr-3">
+ <gl-avatar-link
+ :href="author.webUrl"
+ :data-user-id="authorId"
+ :data-username="author.username"
+ class="gl-float-left gl-mr-3 link-inherit-color js-user-link"
+ >
<gl-avatar :size="32" :src="author.avatarUrl" :entity-name="author.username" />
</gl-avatar-link>
@@ -140,7 +266,7 @@ export default {
<gl-link
v-once
:href="author.webUrl"
- class="js-user-link"
+ class="js-user-link link-inherit-color"
data-testid="user-link"
:data-user-id="authorId"
:data-username="author.username"
@@ -152,15 +278,23 @@ export default {
<span class="note-headline-light note-headline-meta">
<span class="system-note-message"> <slot></slot> </span>
<gl-link
- class="note-timestamp system-note-separator gl-display-block gl-mb-2 gl-font-sm"
+ class="note-timestamp system-note-separator gl-display-block gl-mb-2 gl-font-sm link-inherit-color"
:href="`#note_${noteAnchorId}`"
>
<time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" />
</gl-link>
</span>
</div>
- <div class="gl-display-flex gl-align-items-baseline gl-mt-n2 gl-mr-n2">
+ <div class="gl-display-flex gl-align-items-flex-start gl-mt-n2 gl-mr-n2">
<slot name="resolve-discussion"></slot>
+ <emoji-picker
+ v-if="canAwardEmoji"
+ toggle-class="note-action-button note-emoji-button btn-icon btn-default-tertiary"
+ boundary="viewport"
+ :right="false"
+ data-testid="note-emoji-button"
+ @click="handleAwardEmoji"
+ />
<gl-button
v-if="isEditingAndHasPermissions"
v-gl-tooltip
@@ -175,7 +309,6 @@ export default {
<gl-disclosure-dropdown
v-if="isEditingAndHasPermissions"
v-gl-tooltip.hover
- toggle-class="btn-sm"
icon="ellipsis_v"
category="tertiary"
data-qa-selector="design_discussion_actions_ellipsis_dropdown"
@@ -198,8 +331,14 @@ export default {
></div>
<slot name="resolved-status"></slot>
</template>
+ <design-note-awards-list
+ v-if="awards.length"
+ :awards="awards"
+ :can-award-emoji="note.userPermissions.awardEmoji"
+ @award="handleAwardEmoji"
+ />
<design-reply-form
- v-else
+ v-if="isEditing"
:markdown-preview-path="markdownPreviewPath"
:design-note-mutation="$options.updateNoteMutation"
:mutation-variables="mutationVariables"
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note_awards_list.vue b/app/assets/javascripts/design_management/components/design_notes/design_note_awards_list.vue
new file mode 100644
index 00000000000..f5456f47410
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_notes/design_note_awards_list.vue
@@ -0,0 +1,34 @@
+<script>
+import AwardsList from '~/vue_shared/components/awards_list.vue';
+
+export default {
+ components: {
+ AwardsList,
+ },
+ props: {
+ awards: {
+ type: Array,
+ required: true,
+ },
+ canAwardEmoji: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ currentUserId() {
+ return window.gon.current_user_id;
+ },
+ },
+};
+</script>
+
+<template>
+ <awards-list
+ :awards="awards"
+ :can-award-emoji="canAwardEmoji"
+ :current-user-id="currentUserId"
+ class="gl-px-2 gl-mt-5"
+ @award="$emit('award', $event)"
+ />
+</template>
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
index 7474f8f3298..764c78ff581 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
@@ -233,7 +233,7 @@ export default {
</template>
</markdown-field>
<slot name="resolve-checkbox"></slot>
- <div class="note-form-actions gl-display-flex">
+ <div class="note-form-actions gl-display-flex gl-mt-4!">
<gl-button
ref="submitButton"
:disabled="!hasValue"
diff --git a/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql
index 28224671326..9af4733d5dc 100644
--- a/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql
+++ b/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql
@@ -11,6 +11,15 @@ fragment DesignNote on Note {
bodyHtml
createdAt
resolved
+ awardEmoji {
+ nodes {
+ name
+ user {
+ id
+ name
+ }
+ }
+ }
position {
diffRefs {
...DesignDiffRefs
diff --git a/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql
index e599ab19c2d..acc52e5de59 100644
--- a/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql
+++ b/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql
@@ -1,4 +1,5 @@
fragment DesignNotePermissions on NotePermissions {
adminNote
repositionNote
+ awardEmoji
}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/design_note_award_emoji_toggle.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/design_note_award_emoji_toggle.mutation.graphql
new file mode 100644
index 00000000000..3e274d0b65f
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/design_note_award_emoji_toggle.mutation.graphql
@@ -0,0 +1,6 @@
+mutation designNoteNoteToggleAwardEmoji($awardableId: AwardableID!, $name: String!) {
+ awardEmojiToggle(input: { awardableId: $awardableId, name: $name }) {
+ errors
+ toggledOn
+ }
+}
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index e7308aad785..af7c5a25d94 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -146,12 +146,6 @@ export default {
}
return 'col-12';
},
- designContentWrapperClass() {
- if (this.hasDesigns) {
- return 'gl-bg-gray-10 gl-border gl-border-t-0 gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-5';
- }
- return null;
- },
},
mounted() {
if (this.$route.path === '/designs') {
@@ -359,6 +353,7 @@ export default {
<div
data-testid="designs-root"
class="gl-mt-4"
+ :class="{ 'gl-new-card': showToolbar }"
@mouseenter="toggleOnPasteListener"
@mouseleave="toggleOffPasteListener"
>
@@ -371,11 +366,7 @@ export default {
>
{{ uploadError }}
</gl-alert>
- <header
- v-if="showToolbar"
- class="gl-border gl-px-5 gl-py-4 gl-display-flex gl-justify-content-space-between gl-bg-white gl-rounded-top-base"
- data-testid="design-toolbar-wrapper"
- >
+ <header v-if="showToolbar" class="gl-new-card-header" data-testid="design-toolbar-wrapper">
<div
class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full gl-flex-wrap gl-gap-3"
>
@@ -427,8 +418,13 @@ export default {
</div>
</div>
</header>
- <div :class="designContentWrapperClass">
- <gl-loading-icon v-if="isLoading" size="lg" />
+ <div
+ :class="{
+ 'gl-mx-5': showToolbar,
+ 'gl-new-card-body gl-mx-3!': hasDesigns,
+ }"
+ >
+ <gl-loading-icon v-if="isLoading" size="sm" class="gl-py-4" />
<gl-alert v-else-if="error" variant="danger" :dismissible="false">
{{ $options.i18n.designLoadingError }}
</gl-alert>
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index c0a9643e59e..5149dcc5d17 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -58,7 +58,6 @@ import HiddenFilesWarning from './hidden_files_warning.vue';
import NoChanges from './no_changes.vue';
import TreeList from './tree_list.vue';
import VirtualScrollerScrollSync from './virtual_scroller_scroll_sync';
-import PreRenderer from './pre_renderer.vue';
export default {
name: 'DiffsApp',
@@ -66,7 +65,6 @@ export default {
FindingsDrawer,
DynamicScroller,
DynamicScrollerItem,
- PreRenderer,
VirtualScrollerScrollSync,
CompareVersions,
DiffFile,
@@ -95,6 +93,11 @@ export default {
required: false,
default: '',
},
+ endpointSast: {
+ type: String,
+ required: false,
+ default: '',
+ },
endpointCodequality: {
type: String,
required: false,
@@ -277,6 +280,10 @@ export default {
this.setCodequalityEndpoint(this.endpointCodequality);
}
+ if (this.endpointSast) {
+ this.setSastEndpoint(this.endpointSast);
+ }
+
if (this.shouldShow) {
this.fetchData();
}
@@ -358,11 +365,13 @@ export default {
'moveToNeighboringCommit',
'setBaseConfig',
'setCodequalityEndpoint',
+ 'setSastEndpoint',
'fetchDiffFilesMeta',
'fetchDiffFilesBatch',
'fetchFileByFile',
'fetchCoverageFiles',
'fetchCodequality',
+ 'fetchSast',
'rereadNoteHash',
'startRenderDiffsQueue',
'assignDiscussionsToDiff',
@@ -460,6 +469,10 @@ export default {
this.fetchCodequality();
}
+ if (this.endpointSast) {
+ this.fetchSast();
+ }
+
if (!this.isNotesFetched) {
notesEventHub.$emit('fetchNotesData');
}
@@ -665,22 +678,6 @@ export default {
</dynamic-scroller-item>
</template>
<template #before>
- <pre-renderer :max-length="diffFilesLength">
- <template #default="{ item, index, active }">
- <dynamic-scroller-item :item="item" :active="active">
- <diff-file
- :file="item"
- :reviewed="fileReviews[item.id]"
- :is-first-file="index === 0"
- :is-last-file="index === diffFilesLength - 1"
- :help-page-path="helpPagePath"
- :can-current-user-fork="canCurrentUserFork"
- :view-diffs-file-by-file="viewDiffsFileByFile"
- pre-render
- />
- </dynamic-scroller-item>
- </template>
- </pre-renderer>
<virtual-scroller-scroll-sync v-model="virtualScrollCurrentIndex" />
</template>
</dynamic-scroller>
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index d050f2fb9ae..3746ab9427f 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -2,7 +2,7 @@
import { GlButtonGroup, GlButton, GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
+import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status.vue';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
@@ -133,7 +133,7 @@ export default {
/>
</div>
<div
- class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-flex-grow-1 gl-min-w-0"
+ class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-grow-1 gl-min-w-0"
>
<div class="commit-content" data-qa-selector="commit_content">
<a
diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/diff_code_quality.vue
index f3f05e3d9d9..4ed54ecdf66 100644
--- a/app/assets/javascripts/diffs/components/diff_code_quality.vue
+++ b/app/assets/javascripts/diffs/components/diff_code_quality.vue
@@ -1,18 +1,23 @@
<script>
import { GlButton } from '@gitlab/ui';
-import { NEW_CODE_QUALITY_FINDINGS } from '../i18n';
-import DiffCodeQualityItem from './diff_code_quality_item.vue';
+import { NEW_CODE_QUALITY_FINDINGS, NEW_SAST_FINDINGS } from '../i18n';
+import DiffInlineFindings from './diff_inline_findings.vue';
export default {
i18n: {
- newFindings: NEW_CODE_QUALITY_FINDINGS,
+ newCodeQualityFindings: NEW_CODE_QUALITY_FINDINGS,
+ newSastFindings: NEW_SAST_FINDINGS,
},
- components: { GlButton, DiffCodeQualityItem },
+ components: { GlButton, DiffInlineFindings },
props: {
codeQuality: {
type: Array,
required: true,
},
+ sast: {
+ type: Array,
+ required: true,
+ },
},
};
</script>
@@ -22,19 +27,18 @@ export default {
data-testid="diff-codequality"
class="gl-relative codequality-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10 gl-text-black-normal gl-pl-5 gl-pt-4 gl-pb-4"
>
- <h4
- data-testid="diff-codequality-findings-heading"
- class="gl-mt-0 gl-mb-0 gl-font-base gl-font-regular"
- >
- {{ $options.i18n.newFindings }}
- </h4>
- <ul class="gl-list-style-none gl-mb-0 gl-p-0">
- <diff-code-quality-item
- v-for="finding in codeQuality"
- :key="finding.description"
- :finding="finding"
- />
- </ul>
+ <diff-inline-findings
+ v-if="codeQuality.length"
+ :title="$options.i18n.newCodeQualityFindings"
+ :findings="codeQuality"
+ />
+
+ <diff-inline-findings
+ v-if="sast.length"
+ :title="$options.i18n.newSastFindings"
+ :findings="sast"
+ />
+
<gl-button
data-testid="diff-codequality-close"
category="tertiary"
diff --git a/app/assets/javascripts/diffs/components/diff_code_quality_item.vue b/app/assets/javascripts/diffs/components/diff_code_quality_item.vue
index eede110f46c..727b2a0c099 100644
--- a/app/assets/javascripts/diffs/components/diff_code_quality_item.vue
+++ b/app/assets/javascripts/diffs/components/diff_code_quality_item.vue
@@ -1,7 +1,7 @@
<script>
import { GlLink, GlIcon } from '@gitlab/ui';
import { mapActions } from 'vuex';
-import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants';
+import { getSeverity } from '~/ci/reports/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
@@ -12,14 +12,21 @@ export default {
type: Object,
required: true,
},
+ link: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
- methods: {
- severityClass(severity) {
- return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown;
+ computed: {
+ enhancedFinding() {
+ return getSeverity(this.finding);
},
- severityIcon(severity) {
- return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown;
+ listText() {
+ return `${this.finding.severity} - ${this.finding.description}`;
},
+ },
+ methods: {
toggleDrawer() {
this.setDrawer(this.finding);
},
@@ -33,8 +40,8 @@ export default {
<span class="gl-mr-3">
<gl-icon
:size="12"
- :name="severityIcon(finding.severity)"
- :class="severityClass(finding.severity)"
+ :name="enhancedFinding.name"
+ :class="enhancedFinding.class"
class="codequality-severity-icon"
/>
</span>
@@ -43,12 +50,13 @@ export default {
data-testid="description-button-section"
class="gl-display-flex"
>
- <gl-link category="primary" variant="link" @click="toggleDrawer">
- {{ finding.severity }} - {{ finding.description }}</gl-link
+ <gl-link v-if="link" category="primary" variant="link" @click="toggleDrawer">
+ {{ listText }}</gl-link
>
+ <span v-else>{{ listText }}</span>
</span>
<span v-else data-testid="description-plain-text" class="gl-display-flex">
- {{ finding.severity }} - {{ finding.description }}
+ {{ listText }}
</span>
</li>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index 4d02fd80ba8..1c93cb4d021 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -1,7 +1,9 @@
<script>
import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { mapParallel } from 'ee_else_ce/diffs/components/diff_row_utils';
+import { sprintf } from '~/locale';
+import { createAlert } from '~/alert';
+import { mapParallel, mapParallelNoSast } from 'ee_else_ce/diffs/components/diff_row_utils';
import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue';
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
import { diffViewerModes } from '~/ide/constants';
@@ -12,7 +14,9 @@ import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_d
import NoteForm from '~/notes/components/note_form.vue';
import eventHub from '~/notes/event_hub';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IMAGE_DIFF_POSITION_TYPE } from '../constants';
+import { SAVING_THE_COMMENT_FAILED, SOMETHING_WENT_WRONG } from '../i18n';
import { getDiffMode } from '../store/utils';
import DiffDiscussions from './diff_discussions.vue';
import DiffView from './diff_view.vue';
@@ -32,7 +36,7 @@ export default {
UserAvatarLink,
DiffFileDrafts,
},
- mixins: [diffLineNoteFormMixin, draftCommentsMixin],
+ mixins: [diffLineNoteFormMixin, draftCommentsMixin, glFeatureFlagsMixin()],
props: {
diffFile: {
type: Object,
@@ -51,6 +55,7 @@ export default {
'getCommentFormForDiffFile',
'diffLines',
'fileLineCodequality',
+ 'fileLineSast',
]),
...mapGetters(['getNoteableData', 'noteableType', 'getUserData']),
diffMode() {
@@ -87,8 +92,11 @@ export default {
return this.getUserData;
},
mappedLines() {
- // TODO: Do this data generation when we receive a response to save a computed property being created
- return this.diffLines(this.diffFile).map(mapParallel(this)) || [];
+ if (this.glFeatures.sastReportsInInlineDiff) {
+ return this.diffLines(this.diffFile).map(mapParallel(this)) || [];
+ }
+
+ return this.diffLines(this.diffFile).map(mapParallelNoSast(this)) || [];
},
imageDiscussions() {
return this.diffFile.discussions.filter(
@@ -103,7 +111,7 @@ export default {
},
methods: {
...mapActions('diffs', ['saveDiffDiscussion', 'closeDiffFileCommentForm']),
- handleSaveNote(note) {
+ handleSaveNote(note, parentElement, errorCallback) {
this.saveDiffDiscussion({
note,
formData: {
@@ -116,6 +124,18 @@ export default {
width: this.diffFileCommentForm.width,
height: this.diffFileCommentForm.height,
},
+ }).catch((e) => {
+ const reason = e.response?.data?.errors;
+ const errorMessage = reason
+ ? sprintf(SAVING_THE_COMMENT_FAILED, { reason })
+ : SOMETHING_WENT_WRONG;
+
+ createAlert({
+ message: errorMessage,
+ parent: parentElement,
+ });
+
+ errorCallback();
});
},
},
@@ -143,7 +163,7 @@ export default {
{{ __('Contains only whitespace changes.') }}
<gl-button
category="tertiary"
- variant="info"
+ variant="confirm"
size="small"
class="gl-ml-3"
data-testid="diff-load-file-button"
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 8e1c6cecbd1..4e1ccfc530e 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -25,7 +25,7 @@ import {
FILE_DIFF_POSITION_TYPE,
} from '../constants';
import eventHub from '../event_hub';
-import { DIFF_FILE, GENERIC_ERROR, CONFLICT_TEXT } from '../i18n';
+import { DIFF_FILE, SOMETHING_WENT_WRONG, SAVING_THE_COMMENT_FAILED, CONFLICT_TEXT } from '../i18n';
import { collapsedType, getShortShaFromFile } from '../utils/diff_file';
import DiffDiscussions from './diff_discussions.vue';
import DiffFileHeader from './diff_file_header.vue';
@@ -88,11 +88,6 @@ export default {
required: false,
default: true,
},
- preRender: {
- type: Boolean,
- required: false,
- default: false,
- },
},
idState() {
return {
@@ -104,7 +99,7 @@ export default {
},
i18n: {
...DIFF_FILE,
- genericError: GENERIC_ERROR,
+ genericError: SOMETHING_WENT_WRONG,
},
computed: {
...mapState('diffs', [
@@ -122,7 +117,7 @@ export default {
return getShortShaFromFile(this.file);
},
showLoadingIcon() {
- return this.idState.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed);
+ return this.idState.isLoadingCollapsedDiff;
},
hasDiff() {
return hasDiff(this.file);
@@ -177,9 +172,6 @@ export default {
showLocalFileReviews() {
return Boolean(gon.current_user_id);
},
- codequalityDiffForFile() {
- return this.codequalityDiff?.files?.[this.file.file_path] || [];
- },
isCollapsed() {
if (collapsedType(this.file) !== DIFF_FILE_MANUAL_COLLAPSE) {
return this.viewDiffsFileByFile ? false : this.file.viewer?.automaticallyCollapsed;
@@ -194,9 +186,8 @@ export default {
},
showFileDiscussions() {
return (
- this.glFeatures.commentOnFiles &&
!this.file.viewer?.manuallyCollapsed &&
- (this.fileDiscussions.length || this.file.drafts.length || this.file.hasCommentForm)
+ (this.fileDiscussions.length || this.file.drafts?.length || this.file.hasCommentForm)
);
},
diffFileHash() {
@@ -206,8 +197,6 @@ export default {
watch: {
'file.id': {
handler: function fileIdHandler() {
- if (this.preRender) return;
-
this.manageViewedEffects();
},
},
@@ -220,7 +209,6 @@ export default {
newHash &&
oldHash &&
!this.hasDiff &&
- !this.preRender &&
!this.idState.hasLoadedCollapsedDiff
) {
this.requestDiff();
@@ -229,14 +217,10 @@ export default {
},
},
created() {
- if (this.preRender) return;
-
notesEventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.requestDiff);
eventHub.$on(EVT_EXPAND_ALL_FILES, this.expandAllListener);
},
mounted() {
- if (this.preRender) return;
-
if (this.hasDiff) {
this.postRender();
}
@@ -244,15 +228,12 @@ export default {
this.manageViewedEffects();
},
beforeDestroy() {
- if (this.preRender) return;
-
eventHub.$off(EVT_EXPAND_ALL_FILES, this.expandAllListener);
},
methods: {
...mapActions('diffs', [
'loadCollapsedDiff',
'assignDiscussionsToDiff',
- 'setRenderIt',
'setFileCollapsedByUser',
'saveDiffDiscussion',
'toggleFileCommentForm',
@@ -316,10 +297,6 @@ export default {
.then(() => {
idState.isLoadingCollapsedDiff = false;
idState.hasLoadedCollapsedDiff = true;
-
- if (this.file.file_hash === file.file_hash) {
- this.setRenderIt(this.file);
- }
})
.then(() => {
if (this.file.file_hash !== file.file_hash) return;
@@ -345,7 +322,7 @@ export default {
hideForkMessage() {
this.idState.forkMessageVisible = false;
},
- handleSaveNote(note) {
+ handleSaveNote(note, parentElement, errorCallback) {
this.saveDiffDiscussion({
note,
formData: {
@@ -354,8 +331,23 @@ export default {
diffFile: this.file,
positionType: FILE_DIFF_POSITION_TYPE,
},
+ }).catch((e) => {
+ const reason = e.response?.data?.errors;
+ const errorMessage = reason
+ ? sprintf(SAVING_THE_COMMENT_FAILED, { reason })
+ : SOMETHING_WENT_WRONG;
+
+ createAlert({
+ message: errorMessage,
+ parent: parentElement,
+ });
+
+ errorCallback();
});
},
+ handleSaveDraftNote(note, _, parentElement, errorCallback) {
+ this.addToReview(note, this.$options.FILE_DIFF_POSITION_TYPE, parentElement, errorCallback);
+ },
},
CONFLICT_TEXT,
FILE_DIFF_POSITION_TYPE,
@@ -364,15 +356,14 @@ export default {
<template>
<div
- :id="!preRender && active && file.file_hash"
+ :id="file.file_hash"
:class="{
- 'is-active': currentDiffFileId === file.file_hash,
'comments-disabled': Boolean(file.brokenSymlink),
'has-body': showBody,
'is-virtual-scrolling': isVirtualScrollingEnabled,
}"
:data-path="file.new_path"
- class="diff-file file-holder gl-border-none"
+ class="diff-file file-holder gl-border-none gl-mb-0! gl-pb-5"
>
<diff-file-header
:can-current-user-fork="canCurrentUserFork"
@@ -383,7 +374,6 @@ export default {
:add-merge-request-buttons="true"
:view-diffs-file-by-file="viewDiffsFileByFile"
:show-local-file-reviews="showLocalFileReviews"
- :codequality-diff="codequalityDiffForFile"
class="js-file-title file-title gl-border-1 gl-border-solid gl-border-gray-100"
:class="hasBodyClasses.header"
@toggleFile="handleToggle({ viaUserInteraction: true })"
@@ -412,7 +402,7 @@ export default {
</div>
<template v-else>
<div
- :id="!preRender && active && `diff-content-${file.file_hash}`"
+ :id="`diff-content-${file.file_hash}`"
:class="hasBodyClasses.contentByHash"
data-testid="content-area"
>
@@ -463,7 +453,7 @@ export default {
</template>
</gl-sprintf>
</gl-alert>
- <div v-if="showFileDiscussions" class="gl-border-b" data-testid="file-discussions">
+ <div v-if="showFileDiscussions" data-testid="file-discussions">
<div class="diff-file-discussions-wrapper">
<diff-discussions
v-if="fileDiscussions.length"
@@ -485,9 +475,7 @@ export default {
class="gl-py-3 gl-px-5"
data-testid="file-note-form"
@handleFormUpdate="handleSaveNote"
- @handleFormUpdateAddToReview="
- (note) => addToReview(note, $options.FILE_DIFF_POSITION_TYPE)
- "
+ @handleFormUpdateAddToReview="handleSaveDraftNote"
@cancelForm="toggleFileCommentForm(file.file_path)"
/>
</div>
@@ -538,20 +526,3 @@ export default {
</template>
</div>
</template>
-
-<style>
-@keyframes shadow-fade {
- from {
- box-shadow: 0 0 4px #919191;
- }
-
- to {
- box-shadow: 0 0 0 #dfdfdf;
- }
-}
-
-.diff-file.is-active {
- box-shadow: 0 0 0 #dfdfdf;
- animation: shadow-fade 1.2s 0.1s 1;
-}
-</style>
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 494a20045f7..e336161f952 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -101,11 +101,6 @@ export default {
required: false,
default: false,
},
- codequalityDiff: {
- type: Array,
- required: false,
- default: () => [],
- },
},
idState() {
return {
@@ -212,7 +207,7 @@ export default {
return this.expanded ? __('Hide file contents') : __('Show file contents');
},
showCommentButton() {
- return this.getNoteableData.current_user.can_create_note && this.glFeatures.commentOnFiles;
+ return this.getNoteableData.current_user.can_create_note;
},
},
watch: {
@@ -428,6 +423,7 @@ export default {
toggle-class="btn-icon js-diff-more-actions"
class="gl-pt-0!"
data-qa-selector="dropdown_button"
+ lazy
@show="setMoreActionsShown(true)"
@hidden="setMoreActionsShown(false)"
>
diff --git a/app/assets/javascripts/diffs/components/diff_inline_findings.vue b/app/assets/javascripts/diffs/components/diff_inline_findings.vue
new file mode 100644
index 00000000000..1e9a1825d3e
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_inline_findings.vue
@@ -0,0 +1,32 @@
+<script>
+import DiffCodeQualityItem from './diff_code_quality_item.vue';
+
+export default {
+ components: { DiffCodeQualityItem },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ findings: {
+ type: Array,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h4 data-testid="diff-inline-findings-heading" class="gl-my-0 gl-font-base gl-font-regular">
+ {{ title }}
+ </h4>
+ <ul class="gl-list-style-none gl-mb-0 gl-p-0">
+ <diff-code-quality-item
+ v-for="finding in findings"
+ :key="finding.description"
+ :finding="finding"
+ />
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_line.vue b/app/assets/javascripts/diffs/components/diff_line.vue
index 448272549d3..40e53438bc8 100644
--- a/app/assets/javascripts/diffs/components/diff_line.vue
+++ b/app/assets/javascripts/diffs/components/diff_line.vue
@@ -15,13 +15,22 @@ export default {
parsedCodeQuality() {
return (this.line.left ?? this.line.right)?.codequality;
},
+ parsedSast() {
+ return (this.line.left ?? this.line.right)?.sast;
+ },
codeQualityLineNumber() {
- return this.parsedCodeQuality[0].line;
+ return this.parsedCodeQuality[0]?.line;
+ },
+ sastLineNumber() {
+ return this.parsedSast[0]?.line;
},
},
methods: {
hideCodeQualityFindings() {
- this.$emit('hideCodeQualityFindings', this.codeQualityLineNumber);
+ this.$emit(
+ 'hideCodeQualityFindings',
+ this.codeQualityLineNumber ? this.codeQualityLineNumber : this.sastLineNumber,
+ );
},
},
};
@@ -30,6 +39,7 @@ export default {
<template>
<diff-code-quality
:code-quality="parsedCodeQuality"
+ :sast="parsedSast"
@hideCodeQualityFindings="hideCodeQualityFindings"
/>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index 9ddf5b51c9a..9a3256beff4 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -1,6 +1,7 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
-import { s__, __ } from '~/locale';
+import { s__, __, sprintf } from '~/locale';
+import { createAlert } from '~/alert';
import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
@@ -15,6 +16,7 @@ import {
PARALLEL_DIFF_VIEW_TYPE,
OLD_LINE_TYPE,
} from '../constants';
+import { SAVING_THE_COMMENT_FAILED, SOMETHING_WENT_WRONG } from '../i18n';
export default {
components: {
@@ -207,10 +209,22 @@ export default {
fileHash: this.diffFileHash,
});
}),
- handleSaveNote(note) {
- return this.saveDiffDiscussion({ note, formData: this.formData }).then(() =>
- this.handleCancelCommentForm(),
- );
+ handleSaveNote(note, parentElement, errorCallback) {
+ return this.saveDiffDiscussion({ note, formData: this.formData })
+ .then(() => this.handleCancelCommentForm())
+ .catch((e) => {
+ const reason = e.response?.data?.errors;
+ const errorMessage = reason
+ ? sprintf(SAVING_THE_COMMENT_FAILED, { reason })
+ : SOMETHING_WENT_WRONG;
+
+ createAlert({
+ message: errorMessage,
+ parent: parentElement,
+ });
+
+ errorCallback();
+ });
},
updateStartLine(line) {
this.commentLineStart = line;
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index 1f5c9b4f2f5..3c9770864fa 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -141,6 +141,18 @@ export default {
},
(props) => [props.inline, props.line.right?.codequality?.length].join(':'),
),
+ showSecurityLeft: memoize(
+ (props) => {
+ return props.inline && props.line.left?.sast?.length > 0;
+ },
+ (props) => [props.inline, props.line.left?.sast?.length].join(':'),
+ ),
+ showSecurityRight: memoize(
+ (props) => {
+ return !props.inline && props.line.right?.sast?.length > 0;
+ },
+ (props) => [props.inline, props.line.right?.sast?.length].join(':'),
+ ),
classNameMapCellLeft: memoize(
(props) => {
return utils.classNameMapCell({
@@ -180,10 +192,13 @@ export default {
].join(':'),
),
shouldRenderCommentButton: memoize(
- (props) => {
- return isLoggedIn() && !props.line.isContextLineLeft && !props.line.isMetaLineLeft;
+ (props, side) => {
+ return (
+ isLoggedIn() && !props.line[`isContextLine${side}`] && !props.line[`isMetaLine${side}`]
+ );
},
- (props) => [props.line.isContextLineLeft, props.line.isMetaLineLeft].join(':'),
+ (props, side) =>
+ [props.line[`isContextLine${side}`], props.line[`isMetaLine${side}`]].join(':'),
),
interopLeftAttributes(props) {
if (props.inline) {
@@ -237,7 +252,7 @@ export default {
<span
v-if="
!props.line.left.isConflictMarker &&
- $options.shouldRenderCommentButton(props) &&
+ $options.shouldRenderCommentButton(props, 'Left') &&
!props.line.hasDiscussionsLeft
"
class="add-diff-note tooltip-wrapper has-tooltip"
@@ -322,12 +337,17 @@ export default {
>
<component
:is="$options.CodeQualityGutterIcon"
- v-if="$options.showCodequalityLeft(props)"
+ v-if="$options.showCodequalityLeft(props) || $options.showSecurityLeft(props)"
:code-quality-expanded="props.codeQualityExpanded"
:codequality="props.line.left.codequality"
+ :sast="props.line.left.sast"
:file-path="props.filePath"
@showCodeQualityFindings="
- listeners.toggleCodeQualityFindings(props.line.left.codequality[0].line)
+ listeners.toggleCodeQualityFindings(
+ props.line.left.codequality[0]
+ ? props.line.left.codequality[0].line
+ : props.line.left.sast[0].line,
+ )
"
/>
</div>
@@ -384,7 +404,10 @@ export default {
<div :class="$options.classNameMapCellRight(props)" class="diff-td diff-line-num new_line">
<template v-if="props.line.right.type !== $options.CONFLICT_MARKER_THEIR">
<span
- v-if="$options.shouldRenderCommentButton(props) && !props.line.hasDiscussionsRight"
+ v-if="
+ $options.shouldRenderCommentButton(props, 'Right') &&
+ !props.line.hasDiscussionsRight
+ "
class="add-diff-note tooltip-wrapper has-tooltip"
:title="props.line.right.addCommentTooltip"
>
@@ -455,12 +478,17 @@ export default {
>
<component
:is="$options.CodeQualityGutterIcon"
- v-if="$options.showCodequalityRight(props)"
+ v-if="$options.showCodequalityRight(props) || $options.showSecurityRight(props)"
:codequality="props.line.right.codequality"
+ :sast="props.line.right.sast"
:file-path="props.filePath"
data-testid="codeQualityIcon"
@showCodeQualityFindings="
- listeners.toggleCodeQualityFindings(props.line.right.codequality[0].line)
+ listeners.toggleCodeQualityFindings(
+ props.line.right.codequality[0]
+ ? props.line.right.codequality[0].line
+ : props.line.right.sast[0].line,
+ )
"
/>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js
index a489c96b0c9..28834dab3b3 100644
--- a/app/assets/javascripts/diffs/components/diff_row_utils.js
+++ b/app/assets/javascripts/diffs/components/diff_row_utils.js
@@ -189,3 +189,7 @@ export const mapParallel = (content) => (line) => {
commentRowClasses: hasDiscussions(left) || hasDiscussions(right) ? '' : 'js-temp-notes-holder',
};
};
+
+export const mapParallelNoSast = (content) => {
+ return mapParallel(content);
+};
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index 7c87ea1cbf2..6bacc6839d8 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -57,6 +57,7 @@ export default {
...mapGetters('diffs', ['commitId', 'fileLineCoverage']),
...mapState('diffs', [
'codequalityDiff',
+ 'sastDiff',
'highlightedRow',
'coverageLoaded',
'selectedCommentPosition',
@@ -75,7 +76,10 @@ export default {
);
},
hasCodequalityChanges() {
- return this.codequalityDiff?.files?.[this.diffFile.file_path]?.length > 0;
+ return (
+ this.codequalityDiff?.files?.[this.diffFile.file_path]?.length > 0 ||
+ this.sastDiff?.added?.length > 0
+ );
},
},
created() {
@@ -183,7 +187,10 @@ export default {
);
},
getCodeQualityLine(line) {
- return (line.left ?? line.right)?.codequality?.[0]?.line;
+ return (
+ (line.left ?? line.right)?.codequality?.[0]?.line ||
+ (line.left ?? line.right)?.sast?.[0]?.line
+ );
},
lineDrafts(line, side) {
return (line[side]?.lineDrafts || []).filter((entry) => entry.isDraft);
diff --git a/app/assets/javascripts/diffs/components/pre_renderer.vue b/app/assets/javascripts/diffs/components/pre_renderer.vue
deleted file mode 100644
index e4320c40d2c..00000000000
--- a/app/assets/javascripts/diffs/components/pre_renderer.vue
+++ /dev/null
@@ -1,83 +0,0 @@
-<script>
-export default {
- inject: ['vscrollParent'],
- props: {
- maxLength: {
- type: Number,
- required: true,
- },
- },
- data() {
- return {
- nextIndex: -1,
- nextItem: null,
- startedRender: false,
- width: 0,
- };
- },
- mounted() {
- this.width = this.$el.parentNode.offsetWidth;
-
- this.$_itemsWithSizeWatcher = this.$watch('vscrollParent.itemsWithSize', async () => {
- await this.$nextTick();
-
- const nextItem = this.findNextToRender();
-
- if (nextItem) {
- this.startedRender = true;
- requestIdleCallback(() => {
- this.nextItem = nextItem;
-
- if (this.nextIndex === this.maxLength - 1) {
- this.$nextTick(() => {
- if (this.vscrollParent.itemsWithSize[this.maxLength - 1].size !== 0) {
- this.clearRendering();
- }
- });
- }
- });
- } else if (this.startedRender) {
- this.clearRendering();
- }
- });
- },
- beforeDestroy() {
- this.$_itemsWithSizeWatcher();
- },
- methods: {
- clearRendering() {
- this.nextItem = null;
-
- if (this.maxLength === this.vscrollParent.itemsWithSize.length) {
- this.$_itemsWithSizeWatcher();
- }
- },
- findNextToRender() {
- return this.vscrollParent.itemsWithSize.find(({ size }, index) => {
- const isNext = size === 0;
-
- if (isNext) {
- this.nextIndex = index;
- }
-
- return isNext;
- });
- },
- },
-};
-</script>
-
-<template>
- <div v-if="nextItem" :style="{ width: `${width}px` }" class="gl-absolute diff-file-offscreen">
- <slot
- v-bind="{ item: nextItem.item, index: nextIndex, active: true, itemWithSize: nextItem }"
- ></slot>
- </div>
-</template>
-
-<style scoped>
-.diff-file-offscreen {
- top: -200%;
- left: -200%;
-}
-</style>
diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue
index 2d9ac76b3e4..a705f29ff65 100644
--- a/app/assets/javascripts/diffs/components/settings_dropdown.vue
+++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue
@@ -2,23 +2,22 @@
import {
GlButtonGroup,
GlButton,
- GlDropdown,
+ GlDisclosureDropdown,
GlFormCheckbox,
- GlTooltipDirective,
+ GlTooltip,
} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import { SETTINGS_DROPDOWN } from '../i18n';
export default {
i18n: SETTINGS_DROPDOWN,
- directives: {
- GlTooltip: GlTooltipDirective,
- },
+ toggleId: 'js-show-diff-settings',
components: {
GlButtonGroup,
GlButton,
- GlDropdown,
+ GlDisclosureDropdown,
GlFormCheckbox,
+ GlTooltip,
},
computed: {
...mapGetters('diffs', ['isInlineView', 'isParallelView']),
@@ -43,74 +42,87 @@ export default {
</script>
<template>
- <gl-dropdown
- v-gl-tooltip
- icon="settings"
- :title="$options.i18n.preferences"
- :text="$options.i18n.preferences"
- :text-sr-only="true"
- :aria-label="$options.i18n.preferences"
- :header-text="$options.i18n.preferences"
- toggle-class="js-show-diff-settings"
- right
- >
- <div class="gl-px-3">
- <span class="gl-font-weight-bold gl-display-block gl-mb-2">{{ __('File browser') }}</span>
- <gl-button-group class="gl-display-flex">
- <gl-button
- :class="{ selected: !renderTreeList }"
- class="gl-w-half js-list-view"
- @click="setRenderTreeList({ renderTreeList: false })"
- >
- {{ __('List view') }}
- </gl-button>
- <gl-button
- :class="{ selected: renderTreeList }"
- class="gl-w-half js-tree-view"
- @click="setRenderTreeList({ renderTreeList: true })"
- >
- {{ __('Tree view') }}
- </gl-button>
- </gl-button-group>
- </div>
- <div class="gl-mt-3 gl-px-3">
- <span class="gl-font-weight-bold gl-display-block gl-mb-2">{{ __('Compare changes') }}</span>
- <gl-button-group class="gl-display-flex js-diff-view-buttons">
- <gl-button
- id="inline-diff-btn"
- :class="{ selected: isInlineView }"
- class="gl-w-half js-inline-diff-button"
- data-view-type="inline"
- @click="setInlineDiffViewType"
- >
- {{ __('Inline') }}
- </gl-button>
- <gl-button
- id="parallel-diff-btn"
- :class="{ selected: isParallelView }"
- class="gl-w-half js-parallel-diff-button"
- data-view-type="parallel"
- @click="setParallelDiffViewType"
- >
- {{ __('Side-by-side') }}
- </gl-button>
- </gl-button-group>
- </div>
- <gl-form-checkbox
- data-testid="show-whitespace"
- class="gl-mt-3 gl-ml-3"
- :checked="showWhitespace"
- @input="toggleWhitespace"
- >
- {{ $options.i18n.whitespace }}
- </gl-form-checkbox>
- <gl-form-checkbox
- data-testid="file-by-file"
- class="gl-ml-3 gl-mb-0"
- :checked="viewDiffsFileByFile"
- @input="toggleFileByFile"
+ <div>
+ <gl-disclosure-dropdown
+ :toggle-class="$options.toggleId"
+ :toggle-id="$options.toggleId"
+ icon="settings"
+ :text="$options.i18n.preferences"
+ text-sr-only
+ :aria-label="$options.i18n.preferences"
+ placement="right"
+ :auto-close="false"
>
- {{ $options.i18n.fileByFile }}
- </gl-form-checkbox>
- </gl-dropdown>
+ <slot name="header">
+ <span
+ class="gl-font-weight-bold gl-display-block gl-mb-3 gl-pb-2 gl-text-center gl-border-b"
+ >{{ $options.i18n.preferences }}</span
+ >
+ </slot>
+ <div class="gl-px-3">
+ <span class="gl-font-weight-bold gl-display-block gl-mb-2">{{ __('File browser') }}</span>
+ <gl-button-group class="gl-display-flex">
+ <gl-button
+ :class="{ selected: !renderTreeList }"
+ class="gl-w-half js-list-view"
+ @click="setRenderTreeList({ renderTreeList: false })"
+ >
+ {{ __('List view') }}
+ </gl-button>
+ <gl-button
+ :class="{ selected: renderTreeList }"
+ class="gl-w-half js-tree-view"
+ @click="setRenderTreeList({ renderTreeList: true })"
+ >
+ {{ __('Tree view') }}
+ </gl-button>
+ </gl-button-group>
+ </div>
+ <div class="gl-mt-3 gl-px-3">
+ <span class="gl-font-weight-bold gl-display-block gl-mb-2">{{
+ __('Compare changes')
+ }}</span>
+ <gl-button-group class="gl-display-flex js-diff-view-buttons">
+ <gl-button
+ id="inline-diff-btn"
+ :class="{ selected: isInlineView }"
+ class="gl-w-half js-inline-diff-button"
+ data-view-type="inline"
+ @click="setInlineDiffViewType"
+ >
+ {{ __('Inline') }}
+ </gl-button>
+ <gl-button
+ id="parallel-diff-btn"
+ :class="{ selected: isParallelView }"
+ class="gl-w-half js-parallel-diff-button"
+ data-view-type="parallel"
+ @click="setParallelDiffViewType"
+ >
+ {{ __('Side-by-side') }}
+ </gl-button>
+ </gl-button-group>
+ </div>
+ <gl-form-checkbox
+ data-testid="show-whitespace"
+ class="gl-mt-3 gl-ml-3"
+ :checked="showWhitespace"
+ @input="toggleWhitespace"
+ >
+ {{ $options.i18n.whitespace }}
+ </gl-form-checkbox>
+ <gl-form-checkbox
+ data-testid="file-by-file"
+ class="gl-ml-3 gl-mb-0"
+ :checked="viewDiffsFileByFile"
+ @input="toggleFileByFile"
+ >
+ {{ $options.i18n.fileByFile }}
+ </gl-form-checkbox>
+ </gl-disclosure-dropdown>
+
+ <gl-tooltip :target="$options.toggleId" triggers="hover">{{
+ $options.i18n.preferences
+ }}</gl-tooltip>
+ </div>
</template>
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index b9bfceee6b4..6f17d70b952 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -186,10 +186,10 @@ export default {
v-show="search"
:aria-label="__('Clear search')"
type="button"
- class="position-absolute bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0"
+ class="gl-absolute gl-top-3 bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0"
@click="clearSearch"
>
- <gl-icon name="close" class="gl-absolute gl-top-3 gl-right-1 tree-list-icon" />
+ <gl-icon name="close" class="gl-top-3 gl-right-1 tree-list-icon" />
</button>
</div>
</div>
diff --git a/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js b/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js
index d44dffecc38..fc36153a870 100644
--- a/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js
+++ b/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js
@@ -18,17 +18,18 @@ export default {
if (index < 0) return;
- if (this.vscrollParent.itemsWithSize[index].size) {
- this.scrollToIndex(index);
- } else {
+ this.scrollToIndex(index);
+
+ if (!this.vscrollParent.itemsWithSize[index].size) {
this.$_itemsWithSizeWatcher = this.$watch('vscrollParent.itemsWithSize', async () => {
await this.$nextTick();
if (this.vscrollParent.itemsWithSize[index].size) {
this.$_itemsWithSizeWatcher();
- this.scrollToIndex(index);
await this.$nextTick();
+
+ this.scrollToIndex(index);
}
});
}
@@ -40,10 +41,12 @@ export default {
if (this.$_itemsWithSizeWatcher) this.$_itemsWithSizeWatcher();
},
methods: {
- scrollToIndex(index) {
+ async scrollToIndex(index) {
this.vscrollParent.scrollToItem(index);
this.$emit('update', -1);
+ await this.$nextTick();
+
setTimeout(() => {
handleLocationHash();
});
diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js
index e233a0cef0a..15e3893d22a 100644
--- a/app/assets/javascripts/diffs/i18n.js
+++ b/app/assets/javascripts/diffs/i18n.js
@@ -1,6 +1,5 @@
import { __, s__ } from '~/locale';
-export const GENERIC_ERROR = __('Something went wrong on our end. Please try again!');
export const LOAD_SINGLE_DIFF_FAILED = s__(
"MergeRequest|Can't fetch the diff needed to update this view. Please reload this page.",
);
@@ -58,3 +57,18 @@ export const CONFLICT_TEXT = {
export const HIDE_COMMENTS = __('Hide comments');
export const NEW_CODE_QUALITY_FINDINGS = __('New code quality findings');
+export const NEW_SAST_FINDINGS = __('New Security findings');
+
+export const BUILDING_YOUR_MR = __(
+ 'Building your merge request… This page will update when the build is complete.',
+);
+export const SOMETHING_WENT_WRONG = __('Something went wrong on our end. Please try again!');
+export const SAVING_THE_COMMENT_FAILED = s__(
+ 'MergeRequests|Your comment could not be submitted because %{reason}.',
+);
+export const ERROR_LOADING_FULL_DIFF = s__(
+ 'MergeRequest|Error loading full diff. Please try again.',
+);
+export const ERROR_DISMISSING_SUGESTION_POPOVER = s__(
+ 'MergeRequest|Error dismissing suggestion popover. Please try again.',
+);
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 29cf90dcbe2..b9cf26827f2 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -32,6 +32,7 @@ export default function initDiffsApp(store = notesStore) {
return {
endpointCoverage: dataset.endpointCoverage || '',
endpointCodequality: dataset.endpointCodequality || '',
+ endpointSast: dataset.endpointSast || '',
helpPagePath: dataset.helpPagePath,
currentUser: JSON.parse(dataset.currentUserData) || {},
changesEmptyStateIllustration: dataset.changesEmptyStateIllustration,
@@ -79,6 +80,7 @@ export default function initDiffsApp(store = notesStore) {
props: {
endpointCoverage: this.endpointCoverage,
endpointCodequality: this.endpointCodequality,
+ endpointSast: this.endpointSast,
currentUser: this.currentUser,
helpPagePath: this.helpPagePath,
shouldShow: this.activeTab === 'diffs',
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 029be6ebad9..2a557017953 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -6,13 +6,11 @@ import {
scrollToElement,
} from '~/lib/utils/common_utils';
import { createAlert, VARIANT_WARNING } from '~/alert';
-import { diffViewerModes } from '~/ide/constants';
import axios from '~/lib/utils/axios_utils';
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';
import notesEventHub from '~/notes/event_hub';
import { generateTreeList } from '~/diffs/utils/tree_worker_utils';
import { sortTree } from '~/ide/stores/utils';
@@ -52,9 +50,15 @@ import {
EVT_MR_PREPARED,
FILE_DIFF_POSITION_TYPE,
} from '../constants';
-import { DISCUSSION_SINGLE_DIFF_FAILED, LOAD_SINGLE_DIFF_FAILED } from '../i18n';
+import {
+ DISCUSSION_SINGLE_DIFF_FAILED,
+ LOAD_SINGLE_DIFF_FAILED,
+ BUILDING_YOUR_MR,
+ SOMETHING_WENT_WRONG,
+ ERROR_LOADING_FULL_DIFF,
+ ERROR_DISMISSING_SUGESTION_POPOVER,
+} from '../i18n';
import eventHub from '../event_hub';
-import { isCollapsed } from '../utils/diff_file';
import { markFileReview, setReviewsForMergeRequest } from '../utils/file_reviews';
import { getDerivedMergeRequestInformation } from '../utils/merge_request';
import { queueRedisHllEvents } from '../utils/queue_events';
@@ -84,6 +88,7 @@ export const setBaseConfig = ({ commit }, options) => {
defaultSuggestionCommitMessage,
viewDiffsFileByFile,
mrReviews,
+ diffViewType,
} = options;
commit(types.SET_BASE_CONFIG, {
endpoint,
@@ -98,6 +103,7 @@ export const setBaseConfig = ({ commit }, options) => {
defaultSuggestionCommitMessage,
viewDiffsFileByFile,
mrReviews,
+ diffViewType,
});
Array.from(new Set(Object.values(mrReviews).flat())).forEach((id) => {
@@ -171,7 +177,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
};
const hash = window.location.hash.replace('#', '').split('diff-content-').pop();
let totalLoaded = 0;
- let scrolledVirtualScroller = false;
+ let scrolledVirtualScroller = hash === '';
commit(types.SET_BATCH_LOADING_STATE, 'loading');
commit(types.SET_RETRIEVING_BATCHES, true);
@@ -243,8 +249,6 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
return nextPage;
})
.then((nextPage) => {
- dispatch('startRenderDiffsQueue');
-
if (nextPage) {
return getBatch(nextPage);
}
@@ -290,9 +294,7 @@ export const fetchDiffFilesMeta = ({ commit, state }) => {
.catch((error) => {
if (error.response.status === HTTP_STATUS_NOT_FOUND) {
const alert = createAlert({
- message: __(
- 'Building your merge request… This page will update when the build is complete.',
- ),
+ message: BUILDING_YOUR_MR,
variant: VARIANT_WARNING,
});
@@ -318,7 +320,7 @@ export const fetchCoverageFiles = ({ commit, state }) => {
},
errorCallback: () =>
createAlert({
- message: __('Something went wrong on our end. Please try again!'),
+ message: SOMETHING_WENT_WRONG,
}),
});
@@ -379,10 +381,6 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi
const file = state.diffFiles.find((f) => f.file_hash === discussion.diff_file.file_hash);
if (file) {
- if (!file.renderIt) {
- commit(types.RENDER_FILE, file);
- }
-
if (file.viewer.automaticallyCollapsed) {
notesEventHub.$emit(`loadCollapsedDiff/${file.file_hash}`);
scrollToElement(document.getElementById(file.file_hash));
@@ -400,46 +398,6 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi
}
};
-export const startRenderDiffsQueue = ({ state, commit }) => {
- const diffFilesToRender = state.diffFiles.filter(
- (file) =>
- !file.renderIt &&
- file.viewer &&
- (!isCollapsed(file) || file.viewer.name !== diffViewerModes.text),
- );
- let currentDiffFileIndex = 0;
-
- const checkItem = () => {
- const nextFile = diffFilesToRender[currentDiffFileIndex];
-
- if (nextFile) {
- let retryCount = 0;
- currentDiffFileIndex += 1;
- commit(types.RENDER_FILE, nextFile);
-
- const requestIdle = () =>
- requestIdleCallback((idleDeadline) => {
- // Wait for at least 5ms before trying to render
- // or for 5 tries and then force render the file
- if (idleDeadline.timeRemaining() >= 5 || retryCount > 4) {
- checkItem();
- } else {
- requestIdle();
- retryCount += 1;
- }
- });
-
- requestIdle();
- }
- };
-
- if (diffFilesToRender.length) {
- checkItem();
- }
-};
-
-export const setRenderIt = ({ commit }, file) => commit(types.RENDER_FILE, file);
-
export const setInlineDiffViewType = ({ commit }) => {
commit(types.SET_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE);
@@ -619,12 +577,7 @@ export const saveDiffDiscussion = async ({ state, dispatch }, { note, formData }
if (formData.positionType === FILE_DIFF_POSITION_TYPE) {
dispatch('toggleFileCommentForm', formData.diffFile.file_path);
}
- })
- .catch(() =>
- createAlert({
- message: s__('MergeRequests|Saving the comment failed'),
- }),
- );
+ });
};
export const toggleTreeOpen = ({ commit }, path) => {
@@ -757,7 +710,7 @@ export const cacheTreeListWidth = (_, size) => {
export const receiveFullDiffError = ({ commit }, filePath) => {
commit(types.RECEIVE_FULL_DIFF_ERROR, filePath);
createAlert({
- message: s__('MergeRequest|Error loading full diff. Please try again.'),
+ message: ERROR_LOADING_FULL_DIFF,
});
};
@@ -845,7 +798,7 @@ export const toggleFullDiff = ({ dispatch, commit, getters, state }, filePath) =
}
};
-export function switchToFullDiffFromRenamedFile({ commit, dispatch }, { diffFile }) {
+export function switchToFullDiffFromRenamedFile({ commit }, { diffFile }) {
return axios
.get(diffFile.context_lines_path, {
params: {
@@ -872,8 +825,6 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch }, { diffFile
},
});
commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: diffFile.file_path, lines });
-
- dispatch('startRenderDiffsQueue');
});
}
@@ -895,7 +846,7 @@ export const setSuggestPopoverDismissed = ({ commit, state }) =>
})
.catch(() => {
createAlert({
- message: s__('MergeRequest|Error dismissing suggestion popover. Please try again.'),
+ message: ERROR_DISMISSING_SUGESTION_POPOVER,
});
});
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index a8a831fb269..d82959daa9d 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -153,6 +153,11 @@ export const fileLineCodequality = () => () => {
return null;
};
+// This function is overwritten for the inline SAST feature in EE
+export const fileLineSast = () => () => {
+ return null;
+};
+
/**
* Returns index of a currently selected diff in diffFiles
* @returns {number}
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index 593c28f20ec..d5e1a05f4a5 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -1,11 +1,4 @@
-import { getCookie } from '~/lib/utils/common_utils';
-import { getParameterValues } from '~/lib/utils/url_utility';
-import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants';
-
-const getViewTypeFromQueryString = () => getParameterValues('view')[0];
-
-const viewTypeFromCookie = getCookie(DIFF_VIEW_COOKIE_NAME);
-const defaultViewType = INLINE_DIFF_VIEW_TYPE;
+import { INLINE_DIFF_VIEW_TYPE } from '../../constants';
export default () => ({
isLoading: true,
@@ -25,7 +18,7 @@ export default () => ({
coverageLoaded: false,
mergeRequestDiffs: [],
mergeRequestDiff: null,
- diffViewType: getViewTypeFromQueryString() || viewTypeFromCookie || defaultViewType,
+ diffViewType: INLINE_DIFF_VIEW_TYPE,
tree: [],
treeEntries: {},
showTreeList: true,
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 2786e971f4b..4855ca87e91 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -23,12 +23,6 @@ function updateDiffFilesInState(state, files) {
return Object.assign(state, { diffFiles: files });
}
-function renderFile(file) {
- Object.assign(file, {
- renderIt: true,
- });
-}
-
export default {
[types.SET_BASE_CONFIG](state, options) {
const {
@@ -44,6 +38,7 @@ export default {
defaultSuggestionCommitMessage,
viewDiffsFileByFile,
mrReviews,
+ diffViewType,
} = options;
Object.assign(state, {
endpoint,
@@ -58,6 +53,7 @@ export default {
defaultSuggestionCommitMessage,
viewDiffsFileByFile,
mrReviews,
+ diffViewType,
});
},
@@ -100,10 +96,6 @@ export default {
Object.assign(state, { coverageFiles, coverageLoaded: true });
},
- [types.RENDER_FILE](state, file) {
- renderFile(file);
- },
-
[types.SET_MERGE_REQUEST_DIFFS](state, mergeRequestDiffs) {
Object.assign(state, {
mergeRequestDiffs,
@@ -353,10 +345,6 @@ export default {
file.viewer.manuallyCollapsed = null;
}
}
-
- if (file && !collapsed) {
- renderFile(file);
- }
},
[types.SET_CURRENT_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) {
const file = state.diffFiles.find((f) => f.file_path === filePath);
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 68536d36ac0..97dfd351e67 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -307,7 +307,6 @@ function mergeTwoFiles(target, source) {
...target,
[INLINE_DIFF_LINES_KEY]: missingInline ? source[INLINE_DIFF_LINES_KEY] : originalInline,
parallel_diff_lines: null,
- renderIt: source.renderIt,
collapsed: source.collapsed,
};
}
@@ -388,7 +387,6 @@ function prepareDiffFileLines(file) {
function finalizeDiffFile(file) {
Object.assign(file, {
- renderIt: true,
isShowingFullFile: false,
isLoadingFullFile: false,
discussions: [],
diff --git a/app/assets/javascripts/emoji/components/category.vue b/app/assets/javascripts/emoji/components/category.vue
index 39881979c4f..a72e7df7769 100644
--- a/app/assets/javascripts/emoji/components/category.vue
+++ b/app/assets/javascripts/emoji/components/category.vue
@@ -33,6 +33,9 @@ export default {
this.renderGroup = true;
this.$emit('appear', this.category);
},
+ onClick(emoji) {
+ this.$emit('click', { category: this.category, emoji });
+ },
},
};
</script>
@@ -48,7 +51,7 @@ export default {
:key="index"
:emojis="emojiGroup"
:render-group="renderGroup"
- :click-emoji="(emoji) => $emit('click', emoji)"
+ :click-emoji="(emoji) => onClick(emoji)"
/>
</template>
<p v-else>
diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue
index 840297b870a..0e3dd9f7535 100644
--- a/app/assets/javascripts/emoji/components/picker.vue
+++ b/app/assets/javascripts/emoji/components/picker.vue
@@ -2,7 +2,7 @@
import { GlIcon, GlDropdown, GlSearchBoxByType } from '@gitlab/ui';
import { findLastIndex } from 'lodash';
import VirtualList from 'vue-virtual-scroll-list';
-import { CATEGORY_NAMES } from '~/emoji';
+import { CATEGORY_NAMES, getEmojiCategoryMap, state } from '~/emoji';
import { CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from '../constants';
import Category from './category.vue';
import EmojiList from './emoji_list.vue';
@@ -49,6 +49,7 @@ export default {
categoryNames() {
return CATEGORY_NAMES.filter((c) => {
if (c === FREQUENTLY_USED_KEY) return hasFrequentlyUsedEmojis();
+ if (c === 'custom') return !state.loading && getEmojiCategoryMap().custom.length > 0;
return true;
}).map((category) => ({
name: category,
@@ -66,10 +67,13 @@ export default {
this.$refs.virtualScoller.setScrollTop(top);
},
- selectEmoji(name) {
- this.$emit('click', name);
+ selectEmoji({ category, emoji }) {
+ this.$emit('click', emoji);
this.$refs.dropdown.hide();
- addToFrequentlyUsed(name);
+
+ if (category !== 'custom') {
+ addToFrequentlyUsed(emoji);
+ }
},
getBoundaryElement() {
return this.boundary || document.querySelector('.content-wrapper') || 'scrollParent';
@@ -102,7 +106,20 @@ export default {
@shown="$emit('shown')"
@hidden="$emit('hidden')"
>
- <template #button-content><slot name="button-content"></slot></template>
+ <template #button-content>
+ <slot name="button-content">
+ <gl-icon class="award-control-icon-neutral gl-button-icon gl-icon" name="slight-smile" />
+ <gl-icon
+ class="award-control-icon-positive gl-button-icon gl-icon gl-left-3!"
+ name="smiley"
+ />
+ <gl-icon
+ class="award-control-icon-super-positive gl-button-icon gl-icon gl-left-3!"
+ name="smile"
+ />
+ </slot>
+ <span class="gl-sr-only">{{ __('Add reaction') }}</span>
+ </template>
<gl-search-box-by-type
v-model="searchValue"
class="gl-mx-5! gl-mb-2!"
diff --git a/app/assets/javascripts/emoji/components/utils.js b/app/assets/javascripts/emoji/components/utils.js
index 5eec0992896..2c1c968878b 100644
--- a/app/assets/javascripts/emoji/components/utils.js
+++ b/app/assets/javascripts/emoji/components/utils.js
@@ -52,7 +52,7 @@ export const getEmojiCategories = memoize(async () => {
return Object.freeze(
Object.keys(categories)
- .filter((c) => c !== FREQUENTLY_USED_KEY)
+ .filter((c) => c !== FREQUENTLY_USED_KEY && categories[c].length)
.reduce((acc, category) => {
const emojis = chunk(categories[category], EMOJIS_PER_ROW);
const height = generateCategoryHeight(emojis.length);
diff --git a/app/assets/javascripts/emoji/constants.js b/app/assets/javascripts/emoji/constants.js
index 7970a932095..215ecbfe605 100644
--- a/app/assets/javascripts/emoji/constants.js
+++ b/app/assets/javascripts/emoji/constants.js
@@ -3,6 +3,7 @@ export const FREQUENTLY_USED_COOKIE_KEY = 'frequently_used_emojis';
export const CATEGORY_ICON_MAP = {
[FREQUENTLY_USED_KEY]: 'history',
+ custom: 'tanuki',
activity: 'dumbbell',
people: 'smiley',
nature: 'nature',
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js
index 4484bc03737..1fa81a000a5 100644
--- a/app/assets/javascripts/emoji/index.js
+++ b/app/assets/javascripts/emoji/index.js
@@ -1,14 +1,22 @@
+import Vue from 'vue';
import { escape, minBy } from 'lodash';
import emojiRegexFactory from 'emoji-regex';
import emojiAliases from 'emojis/aliases.json';
+import createApolloClient from '~/lib/graphql';
import { setAttributes } from '~/lib/utils/dom_utils';
import { getEmojiScoreWithIntent } from '~/emoji/utils';
import AccessorUtilities from '../lib/utils/accessor';
import axios from '../lib/utils/axios_utils';
+import customEmojiQuery from './queries/custom_emoji.query.graphql';
import { CACHE_KEY, CACHE_VERSION_KEY, CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants';
let emojiMap = null;
let validEmojiNames = null;
+
+export const state = Vue.observable({
+ loading: true,
+});
+
export const FALLBACK_EMOJI_KEY = 'grey_question';
// Keep the version in sync with `lib/gitlab/emoji.rb`
@@ -53,9 +61,43 @@ async function loadEmojiWithNames() {
}, {});
}
+export async function loadCustomEmojiWithNames() {
+ if (document.body?.dataset?.groupFullPath && window.gon?.features?.customEmoji) {
+ const client = createApolloClient();
+ const { data } = await client.query({
+ query: customEmojiQuery,
+ variables: {
+ groupPath: document.body.dataset.groupFullPath,
+ },
+ });
+
+ return data?.group?.customEmoji?.nodes?.reduce((acc, e) => {
+ // Map the custom emoji into the format of the normal emojis
+ acc[e.name] = {
+ c: 'custom',
+ d: e.name,
+ e: undefined,
+ name: e.name,
+ src: e.url,
+ u: 'custom',
+ };
+
+ return acc;
+ }, {});
+ }
+
+ return {};
+}
+
async function prepareEmojiMap() {
- emojiMap = await loadEmojiWithNames();
- validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
+ return Promise.all([loadEmojiWithNames(), loadCustomEmojiWithNames()]).then((values) => {
+ emojiMap = {
+ ...values[0],
+ ...values[1],
+ };
+ validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
+ state.loading = false;
+ });
}
export function initEmojiMap() {
@@ -84,6 +126,10 @@ export function getAllEmoji() {
return emojiMap;
}
+export function findCustomEmoji(name) {
+ return emojiMap[name];
+}
+
function getAliasesMatchingQuery(query) {
return Object.keys(emojiAliases)
.filter((alias) => alias.includes(query))
@@ -176,7 +222,7 @@ export const CATEGORY_NAMES = Object.keys(CATEGORY_ICON_MAP);
let emojiCategoryMap;
export function getEmojiCategoryMap() {
- if (!emojiCategoryMap) {
+ if (!emojiCategoryMap && emojiMap) {
emojiCategoryMap = CATEGORY_NAMES.reduce((acc, category) => {
if (category === FREQUENTLY_USED_KEY) {
return acc;
@@ -218,10 +264,11 @@ export function getEmojiInfo(query, fallback = true) {
}
export function emojiFallbackImageSrc(inputName) {
- const { name } = getEmojiInfo(inputName);
- return `${gon.asset_host || ''}${
- gon.relative_url_root || ''
- }/-/emojis/${EMOJI_VERSION}/${name}.png`;
+ const { name, src } = getEmojiInfo(inputName);
+ return (
+ src ||
+ `${gon.asset_host || ''}${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/${name}.png`
+ );
}
export function emojiImageTag(name, src) {
@@ -232,8 +279,6 @@ export function emojiImageTag(name, src) {
title: `:${name}:`,
alt: `:${name}:`,
src,
- width: '16',
- height: '16',
align: 'absmiddle',
});
diff --git a/app/assets/javascripts/emoji/queries/custom_emoji.query.graphql b/app/assets/javascripts/emoji/queries/custom_emoji.query.graphql
new file mode 100644
index 00000000000..951027ec274
--- /dev/null
+++ b/app/assets/javascripts/emoji/queries/custom_emoji.query.graphql
@@ -0,0 +1,12 @@
+query getCustomEmoji($groupPath: ID!) {
+ group(fullPath: $groupPath) {
+ id
+ customEmoji {
+ nodes {
+ id
+ name
+ url
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/components/edit_environment.vue b/app/assets/javascripts/environments/components/edit_environment.vue
index 7905c5cf572..a2405d23924 100644
--- a/app/assets/javascripts/environments/components/edit_environment.vue
+++ b/app/assets/javascripts/environments/components/edit_environment.vue
@@ -1,10 +1,10 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { createAlert } from '~/alert';
-import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getEnvironment from '../graphql/queries/environment.query.graphql';
+import getEnvironmentWithNamespace from '../graphql/queries/environment_with_namespace.graphql';
import updateEnvironment from '../graphql/mutations/update_environment.mutation.graphql';
import EnvironmentForm from './environment_form.vue';
@@ -14,72 +14,42 @@ export default {
EnvironmentForm,
},
mixins: [glFeatureFlagsMixin()],
- inject: ['projectEnvironmentsPath', 'updateEnvironmentPath', 'projectPath'],
- props: {
- environment: {
- required: true,
- type: Object,
- },
- },
+ inject: ['projectEnvironmentsPath', 'projectPath', 'environmentName'],
apollo: {
environment: {
- query: getEnvironment,
+ query() {
+ return this.glFeatures?.kubernetesNamespaceForEnvironment
+ ? getEnvironmentWithNamespace
+ : getEnvironment;
+ },
variables() {
return {
- environmentName: this.environment.name,
+ environmentName: this.environmentName,
projectFullPath: this.projectPath,
};
},
update(data) {
- this.formEnvironment = data?.project?.environment || {};
+ const result = data?.project?.environment || {};
+ this.formEnvironment = { ...result, clusterAgentId: result?.clusterAgent?.id };
},
},
},
data() {
return {
- isQueryLoading: false,
loading: false,
formEnvironment: null,
};
},
- mounted() {
- if (this.glFeatures?.environmentSettingsToGraphql) {
- this.fetchWithGraphql();
- } else {
- this.formEnvironment = {
- id: this.environment.id,
- name: this.environment.name,
- externalUrl: this.environment.external_url,
- };
- }
+ computed: {
+ isQueryLoading() {
+ return this.$apollo.queries.environment.loading;
+ },
},
methods: {
- async fetchWithGraphql() {
- this.$apollo.addSmartQuery('environmentData', {
- variables() {
- return { environmentName: this.environment.name, projectFullPath: this.projectPath };
- },
- query: getEnvironment,
- update(data) {
- const result = data?.project?.environment || {};
- this.formEnvironment = { ...result, clusterAgentId: result?.clusterAgent?.id };
- },
- watchLoading: (isLoading) => {
- this.isQueryLoading = isLoading;
- },
- });
- },
onChange(environment) {
this.formEnvironment = environment;
},
- onSubmit() {
- if (this.glFeatures?.environmentSettingsToGraphql) {
- this.updateWithGraphql();
- } else {
- this.updateWithAxios();
- }
- },
- async updateWithGraphql() {
+ async onSubmit() {
this.loading = true;
try {
const { data } = await this.$apollo.mutate({
@@ -89,6 +59,7 @@ export default {
id: this.formEnvironment.id,
externalUrl: this.formEnvironment.externalUrl,
clusterAgentId: this.formEnvironment.clusterAgentId,
+ kubernetesNamespace: this.formEnvironment.kubernetesNamespace,
},
},
});
@@ -111,20 +82,6 @@ export default {
this.loading = false;
}
},
- updateWithAxios() {
- this.loading = true;
- axios
- .put(this.updateEnvironmentPath, {
- id: this.formEnvironment.id,
- external_url: this.formEnvironment.externalUrl,
- })
- .then(({ data: { path } }) => visitUrl(path))
- .catch((error) => {
- const message = error.response.data.message[0];
- createAlert({ message });
- this.loading = false;
- });
- },
},
};
</script>
diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue
index 266b221b481..1bff013b9c2 100644
--- a/app/assets/javascripts/environments/components/environment_form.vue
+++ b/app/assets/javascripts/environments/components/environment_form.vue
@@ -7,6 +7,7 @@ import {
GlCollapsibleListbox,
GlLink,
GlSprintf,
+ GlAlert,
} from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { isAbsolute } from '~/lib/utils/url_utility';
@@ -15,7 +16,10 @@ import {
ENVIRONMENT_NEW_HELP_TEXT,
ENVIRONMENT_EDIT_HELP_TEXT,
} from 'ee_else_ce/environments/constants';
+import csrf from '~/lib/utils/csrf';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import getNamespacesQuery from '../graphql/queries/k8s_namespaces.query.graphql';
import getUserAuthorizedAgents from '../graphql/queries/user_authorized_agents.query.graphql';
export default {
@@ -27,11 +31,13 @@ export default {
GlCollapsibleListbox,
GlLink,
GlSprintf,
+ GlAlert,
},
mixins: [glFeatureFlagsMixin()],
inject: {
protectedEnvironmentSettingsPath: { default: '' },
projectPath: { default: '' },
+ kasTunnelUrl: { default: '' },
},
props: {
environment: {
@@ -64,11 +70,13 @@ export default {
urlFeedback: __('The URL should start with http:// or https://'),
agentLabel: s__('Environments|GitLab agent'),
agentHelpText: s__('Environments|Select agent'),
+ namespaceLabel: s__('Environments|Kubernetes namespace (optional)'),
+ namespaceHelpText: s__('Environments|Select namespace'),
save: __('Save'),
cancel: __('Cancel'),
reset: __('Reset'),
},
- helpPagePath: helpPagePath('ci/environments/index.md'),
+ environmentsHelpPagePath: helpPagePath('ci/environments/index.md'),
renamingDisabledHelpPagePath: helpPagePath('ci/environments/index.md', {
anchor: 'rename-an-environment',
}),
@@ -81,10 +89,41 @@ export default {
userAccessAuthorizedAgents: [],
loadingAgentsList: false,
selectedAgentId: this.environment.clusterAgentId,
- searchTerm: '',
+ agentSearchTerm: '',
+ selectedNamespace: this.environment.kubernetesNamespace,
+ k8sNamespaces: [],
+ namespaceSearchTerm: '',
+ kubernetesError: '',
};
},
+ apollo: {
+ k8sNamespaces: {
+ query: getNamespacesQuery,
+ skip() {
+ return !this.showNamespaceSelector;
+ },
+ variables() {
+ return {
+ configuration: this.k8sAccessConfiguration,
+ };
+ },
+ update(data) {
+ return data?.k8sNamespaces || [];
+ },
+ error(error) {
+ this.kubernetesError = error.message;
+ },
+ result(result) {
+ if (!result?.error && !result.errors?.length) {
+ this.kubernetesError = null;
+ }
+ },
+ },
+ },
computed: {
+ loadingNamespacesList() {
+ return this.$apollo.queries.k8sNamespaces.loading;
+ },
isNameDisabled() {
return Boolean(this.environment.id);
},
@@ -105,7 +144,7 @@ export default {
};
});
},
- dropdownToggleText() {
+ agentDropdownToggleText() {
if (!this.selectedAgentId) {
return this.$options.i18n.agentHelpText;
}
@@ -115,13 +154,48 @@ export default {
return selectedAgentById?.text || this.environment.clusterAgent?.name;
},
filteredAgentsList() {
- const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
+ const lowerCasedSearchTerm = this.agentSearchTerm.toLowerCase();
return this.agentsList.filter((item) =>
item.text.toLowerCase().includes(lowerCasedSearchTerm),
);
},
- showAgentsSelect() {
- return this.glFeatures?.environmentSettingsToGraphql;
+ namespacesList() {
+ return this.k8sNamespaces.map((item) => {
+ return {
+ value: item.metadata.name,
+ text: item.metadata.name,
+ };
+ });
+ },
+ filteredNamespacesList() {
+ const lowerCasedSearchTerm = this.namespaceSearchTerm.toLowerCase();
+ return this.namespacesList.filter((item) =>
+ item.text.toLowerCase().includes(lowerCasedSearchTerm),
+ );
+ },
+ isKasKubernetesNamespaceAvailable() {
+ return this.glFeatures?.kubernetesNamespaceForEnvironment;
+ },
+ showNamespaceSelector() {
+ return Boolean(this.isKasKubernetesNamespaceAvailable && this.selectedAgentId);
+ },
+ namespaceDropdownToggleText() {
+ return this.selectedNamespace || this.$options.i18n.namespaceHelpText;
+ },
+ k8sAccessConfiguration() {
+ if (!this.showNamespaceSelector) {
+ return null;
+ }
+ return {
+ basePath: this.kasTunnelUrl,
+ baseOptions: {
+ headers: {
+ 'GitLab-Agent-Id': getIdFromGraphQLId(this.selectedAgentId),
+ ...csrf.headers,
+ },
+ withCredentials: true,
+ },
+ };
},
},
watch: {
@@ -151,7 +225,14 @@ export default {
});
},
onAgentSearch(search) {
- this.searchTerm = search;
+ this.agentSearchTerm = search;
+ },
+ onAgentChange($event) {
+ this.selectedNamespace = null;
+ this.onChange({ ...this.environment, clusterAgentId: $event, kubernetesNamespace: null });
+ },
+ onNamespaceSearch(search) {
+ this.namespaceSearchTerm = search;
},
},
};
@@ -171,7 +252,9 @@ export default {
>
<template #link="{ content }">
<gl-link
- :href="showEditHelp ? protectedEnvironmentSettingsPath : $options.helpPagePath"
+ :href="
+ showEditHelp ? protectedEnvironmentSettingsPath : $options.environmentsHelpPagePath
+ "
>{{ content }}</gl-link
>
</template>
@@ -223,29 +306,53 @@ export default {
/>
</gl-form-group>
- <gl-form-group
- v-if="showAgentsSelect"
- :label="$options.i18n.agentLabel"
- label-for="environment_agent"
- >
+ <gl-form-group :label="$options.i18n.agentLabel" label-for="environment_agent">
<gl-collapsible-listbox
id="environment_agent"
v-model="selectedAgentId"
class="gl-w-full"
+ data-testid="agent-selector"
block
:items="filteredAgentsList"
:loading="loadingAgentsList"
- :toggle-text="dropdownToggleText"
+ :toggle-text="agentDropdownToggleText"
:header-text="$options.i18n.agentHelpText"
:reset-button-label="$options.i18n.reset"
:searchable="true"
@shown="getAgentsList"
@search="onAgentSearch"
- @select="onChange({ ...environment, clusterAgentId: $event })"
+ @select="onAgentChange"
@reset="onChange({ ...environment, clusterAgentId: null })"
/>
</gl-form-group>
+ <gl-form-group
+ v-if="showNamespaceSelector"
+ :label="$options.i18n.namespaceLabel"
+ label-for="environment_namespace"
+ >
+ <gl-alert v-if="kubernetesError" variant="warning" :dismissible="false" class="gl-mb-5">
+ {{ kubernetesError }}
+ </gl-alert>
+ <gl-collapsible-listbox
+ v-else
+ id="environment_namespace"
+ v-model="selectedNamespace"
+ class="gl-w-full"
+ data-testid="namespace-selector"
+ block
+ :items="filteredNamespacesList"
+ :loading="loadingNamespacesList"
+ :toggle-text="namespaceDropdownToggleText"
+ :header-text="$options.i18n.namespaceHelpText"
+ :reset-button-label="$options.i18n.reset"
+ :searchable="true"
+ @search="onNamespaceSearch"
+ @select="onChange({ ...environment, kubernetesNamespace: $event })"
+ @reset="onChange({ ...environment, kubernetesNamespace: null })"
+ />
+ </gl-form-group>
+
<div class="gl-mr-6">
<gl-button
:loading="loading"
diff --git a/app/assets/javascripts/environments/components/new_environment.vue b/app/assets/javascripts/environments/components/new_environment.vue
index 3e5f4070066..c6bc94b0b80 100644
--- a/app/assets/javascripts/environments/components/new_environment.vue
+++ b/app/assets/javascripts/environments/components/new_environment.vue
@@ -1,8 +1,6 @@
<script>
import { createAlert } from '~/alert';
-import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import createEnvironment from '../graphql/mutations/create_environment.mutation.graphql';
import EnvironmentForm from './environment_form.vue';
@@ -10,13 +8,13 @@ export default {
components: {
EnvironmentForm,
},
- mixins: [glFeatureFlagsMixin()],
inject: ['projectEnvironmentsPath', 'projectPath'],
data() {
return {
environment: {
name: '',
externalUrl: '',
+ clusterAgentId: null,
},
loading: false,
};
@@ -25,14 +23,7 @@ export default {
onChange(env) {
this.environment = env;
},
- onSubmit() {
- if (this.glFeatures?.environmentSettingsToGraphql) {
- this.createWithGraphql();
- } else {
- this.createWithAxios();
- }
- },
- async createWithGraphql() {
+ async onSubmit() {
this.loading = true;
try {
const { data } = await this.$apollo.mutate({
@@ -43,6 +34,7 @@ export default {
externalUrl: this.environment.externalUrl,
projectPath: this.projectPath,
clusterAgentId: this.environment.clusterAgentId,
+ kubernetesNamespace: this.environment.kubernetesNamespace,
},
},
});
@@ -65,20 +57,6 @@ export default {
this.loading = false;
}
},
- createWithAxios() {
- this.loading = true;
- axios
- .post(this.projectEnvironmentsPath, {
- name: this.environment.name,
- external_url: this.environment.externalUrl,
- })
- .then(({ data: { path } }) => visitUrl(path))
- .catch((error) => {
- const message = error.response.data.message[0];
- createAlert({ message });
- this.loading = false;
- });
- },
},
};
</script>
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index 1f3d429cc3e..fda1c85f739 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -14,6 +14,7 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import isLastDeployment from '../graphql/queries/is_last_deployment.query.graphql';
import getEnvironmentClusterAgent from '../graphql/queries/environment_cluster_agent.query.graphql';
+import getEnvironmentClusterAgentWithNamespace from '../graphql/queries/environment_cluster_agent_with_namespace.query.graphql';
import ExternalUrl from './environment_external_url.vue';
import Actions from './environment_actions.vue';
import StopComponent from './environment_stop.vue';
@@ -82,7 +83,7 @@ export default {
tierTooltip: s__('Environment|Deployment tier'),
},
data() {
- return { visible: false, clusterAgent: null };
+ return { visible: false, clusterAgent: null, kubernetesNamespace: '' };
},
computed: {
icon() {
@@ -164,11 +165,8 @@ export default {
rolloutStatus() {
return this.environment?.rolloutStatus;
},
- isKubernetesOverviewAvailable() {
- return this.glFeatures?.kasUserAccessProject;
- },
- showKubernetesOverview() {
- return Boolean(this.isKubernetesOverviewAvailable && this.clusterAgent);
+ isKubernetesNamespaceAvailable() {
+ return this.glFeatures?.kubernetesNamespaceForEnvironment;
},
},
methods: {
@@ -180,15 +178,20 @@ export default {
}
},
getClusterAgent() {
- if (!this.isKubernetesOverviewAvailable || this.clusterAgent) return;
+ if (this.clusterAgent) return;
this.$apollo.addSmartQuery('environmentClusterAgent', {
variables() {
return { environmentName: this.environment.name, projectFullPath: this.projectPath };
},
- query: getEnvironmentClusterAgent,
+ query() {
+ return this.isKubernetesNamespaceAvailable
+ ? getEnvironmentClusterAgentWithNamespace
+ : getEnvironmentClusterAgent;
+ },
update(data) {
this.clusterAgent = data?.project?.environment?.clusterAgent;
+ this.kubernetesNamespace = data?.project?.environment?.kubernetesNamespace || '';
},
});
},
@@ -368,11 +371,8 @@ export default {
</template>
</gl-sprintf>
</div>
- <div v-if="showKubernetesOverview" :class="$options.kubernetesOverviewClasses">
- <kubernetes-overview
- :cluster-agent="clusterAgent"
- :namespace="environment.kubernetesNamespace"
- />
+ <div v-if="clusterAgent" :class="$options.kubernetesOverviewClasses">
+ <kubernetes-overview :cluster-agent="clusterAgent" :namespace="kubernetesNamespace" />
</div>
<div v-if="rolloutStatus" :class="$options.deployBoardClasses">
<deploy-board-wrapper
diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue
index 95ece2b653e..b583694e154 100644
--- a/app/assets/javascripts/environments/components/stop_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue
@@ -1,10 +1,14 @@
<script>
import { GlSprintf, GlTooltipDirective, GlModal } from '@gitlab/ui';
import { __, s__ } from '~/locale';
+import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility';
import eventHub from '../event_hub';
import stopEnvironmentMutation from '../graphql/mutations/stop_environment.mutation.graphql';
export default {
+ yamlDocsLink: `${DOCS_URL_IN_EE_DIR}/ee/ci/yaml/`,
+ stoppingEnvironmentDocsLink: `${DOCS_URL_IN_EE_DIR}/environments/#stopping-an-environment`,
+
id: 'stop-environment-modal',
name: 'StopEnvironmentModal',
@@ -98,18 +102,15 @@ export default {
<strong>{{ content }}</strong>
</template>
<template #ciConfigLink="{ content }">
- <a href="https://docs.gitlab.com/ee/ci/yaml/" target="_blank" rel="noopener noreferrer">
+ <a :href="$options.yamlDocsLink" target="_blank" rel="noopener noreferrer">
{{ content }}</a
>
</template>
</gl-sprintf>
</p>
- <a
- href="https://docs.gitlab.com/ee/ci/environments/#stopping-an-environment"
- target="_blank"
- rel="noopener noreferrer"
- >{{ s__('Environments|Learn more about stopping environments') }}</a
- >
+ <a :href="$options.stoppingEnvironmentDocsLink" target="_blank" rel="noopener noreferrer">{{
+ s__('Environments|Learn more about stopping environments')
+ }}</a>
</div>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js
index 2b178964c37..dc9481a5429 100644
--- a/app/assets/javascripts/environments/constants.js
+++ b/app/assets/javascripts/environments/constants.js
@@ -108,3 +108,19 @@ export const PHASE_RUNNING = 'Running';
export const PHASE_PENDING = 'Pending';
export const PHASE_SUCCEEDED = 'Succeeded';
export const PHASE_FAILED = 'Failed';
+
+const ERROR_UNAUTHORIZED = 'unauthorized';
+const ERROR_FORBIDDEN = 'forbidden';
+const ERROR_NOT_FOUND = 'not found';
+const ERROR_OTHER = 'other';
+
+export const CLUSTER_AGENT_ERROR_MESSAGES = {
+ [ERROR_UNAUTHORIZED]: s__(
+ 'Environment|Unauthorized to access the cluster agent from this environment. Check your authentication and try again.',
+ ),
+ [ERROR_FORBIDDEN]: s__(
+ 'Environment|Forbidden to access the cluster agent from this environment.',
+ ),
+ [ERROR_NOT_FOUND]: s__('Environment|Cluster agent not found.'),
+ [ERROR_OTHER]: s__('Environment|There was an error connecting to the cluster agent.'),
+};
diff --git a/app/assets/javascripts/environments/edit.js b/app/assets/javascripts/environments/edit.js
index b26d96e15bd..3f22b83e618 100644
--- a/app/assets/javascripts/environments/edit.js
+++ b/app/assets/javascripts/environments/edit.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { removeLastSlashInUrlPath } from '~/lib/utils/url_utility';
import EditEnvironment from './components/edit_environment.vue';
import { apolloProvider } from './graphql/client';
@@ -12,10 +13,10 @@ export default (el) => {
const {
projectEnvironmentsPath,
- updateEnvironmentPath,
protectedEnvironmentSettingsPath,
projectPath,
- environment,
+ environmentName,
+ kasTunnelUrl,
} = el.dataset;
return new Vue({
@@ -23,16 +24,13 @@ export default (el) => {
apolloProvider: apolloProvider(),
provide: {
projectEnvironmentsPath,
- updateEnvironmentPath,
protectedEnvironmentSettingsPath,
projectPath,
+ environmentName,
+ kasTunnelUrl: removeLastSlashInUrlPath(kasTunnelUrl),
},
render(h) {
- return h(EditEnvironment, {
- props: {
- environment: JSON.parse(environment),
- },
- });
+ return h(EditEnvironment);
},
});
};
diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js
index 6d06cff06b9..553b06e632f 100644
--- a/app/assets/javascripts/environments/graphql/client.js
+++ b/app/assets/javascripts/environments/graphql/client.js
@@ -8,6 +8,7 @@ import environmentToStopQuery from './queries/environment_to_stop.query.graphql'
import k8sPodsQuery from './queries/k8s_pods.query.graphql';
import k8sServicesQuery from './queries/k8s_services.query.graphql';
import k8sWorkloadsQuery from './queries/k8s_workloads.query.graphql';
+import k8sNamespacesQuery from './queries/k8s_namespaces.query.graphql';
import { resolvers } from './resolvers';
import typeDefs from './typedefs.graphql';
@@ -161,6 +162,14 @@ export const apolloProvider = (endpoint) => {
},
},
});
+ cache.writeQuery({
+ query: k8sNamespacesQuery,
+ data: {
+ metadata: {
+ name: null,
+ },
+ },
+ });
return new VueApollo({
defaultClient,
});
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_namespace.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_namespace.query.graphql
new file mode 100644
index 00000000000..5e72c2dac20
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_namespace.query.graphql
@@ -0,0 +1,20 @@
+query getEnvironmentClusterAgentWithNamespace($projectFullPath: ID!, $environmentName: String) {
+ project(fullPath: $projectFullPath) {
+ id
+ environment(name: $environmentName) {
+ id
+ kubernetesNamespace
+ clusterAgent {
+ id
+ name
+ webPath
+ tokens {
+ nodes {
+ id
+ lastUsedAt
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_with_namespace.graphql b/app/assets/javascripts/environments/graphql/queries/environment_with_namespace.graphql
new file mode 100644
index 00000000000..42796f982b6
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/environment_with_namespace.graphql
@@ -0,0 +1,15 @@
+query getEnvironmentWithNamespace($projectFullPath: ID!, $environmentName: String) {
+ project(fullPath: $projectFullPath) {
+ id
+ environment(name: $environmentName) {
+ id
+ name
+ externalUrl
+ kubernetesNamespace
+ clusterAgent {
+ id
+ name
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/k8s_namespaces.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_namespaces.query.graphql
new file mode 100644
index 00000000000..c05d09b6ca2
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/k8s_namespaces.query.graphql
@@ -0,0 +1,7 @@
+query getK8sNamespaces($configuration: LocalConfiguration) {
+ k8sNamespaces(configuration: $configuration) @client {
+ metadata {
+ name
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js
index 044e7927606..8cfe44c5a05 100644
--- a/app/assets/javascripts/environments/graphql/resolvers.js
+++ b/app/assets/javascripts/environments/graphql/resolvers.js
@@ -6,6 +6,7 @@ import {
parseIntPagination,
normalizeHeaders,
} from '~/lib/utils/common_utils';
+import { humanizeClusterErrors } from '../helpers/k8s_integration_helper';
import pollIntervalQuery from './queries/poll_interval.query.graphql';
import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql';
@@ -72,6 +73,11 @@ const mapWorkloadItems = (items, kind) => {
});
};
+const handleClusterError = (err) => {
+ const error = err?.response?.data?.message ? new Error(err.response.data.message) : err;
+ throw error;
+};
+
export const resolvers = (endpoint) => ({
Query: {
environmentApp(_context, { page, scope, search }, { cache }) {
@@ -124,8 +130,7 @@ export const resolvers = (endpoint) => ({
return podsApi
.then((res) => res?.data?.items || [])
.catch((err) => {
- const error = err?.response?.data?.message ? new Error(err.response.data.message) : err;
- throw error;
+ handleClusterError(err);
});
},
k8sServices(_, { configuration }) {
@@ -148,8 +153,7 @@ export const resolvers = (endpoint) => ({
});
})
.catch((err) => {
- const error = err?.response?.data?.message ? new Error(err.response.data.message) : err;
- throw error;
+ handleClusterError(err);
});
},
k8sWorkloads(_, { configuration, namespace }) {
@@ -206,6 +210,19 @@ export const resolvers = (endpoint) => ({
return summaryList;
});
},
+ k8sNamespaces(_, { configuration }) {
+ const coreV1Api = new CoreV1Api(new Configuration(configuration));
+ const namespacesApi = coreV1Api.listCoreV1Namespace();
+
+ return namespacesApi
+ .then((res) => {
+ return res?.data?.items || [];
+ })
+ .catch((err) => {
+ const error = err?.response?.data?.reason || err;
+ throw new Error(humanizeClusterErrors(error));
+ });
+ },
},
Mutation: {
stopEnvironmentREST(_, { environment }, { client }) {
diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql
index 7e46385946f..e2c22dda554 100644
--- a/app/assets/javascripts/environments/graphql/typedefs.graphql
+++ b/app/assets/javascripts/environments/graphql/typedefs.graphql
@@ -160,6 +160,12 @@ type LocalK8sWorkloads {
JobList: [localK8sJob]
CronJobList: [localK8sCronJob]
}
+type k8sNamespaceMetadata {
+ name: String
+}
+type LocalK8sNamespaces {
+ metadata: k8sNamespaceMetadata
+}
extend type Query {
environmentApp(page: Int, scope: String): LocalEnvironmentApp
diff --git a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js
index 45c65c93a91..e49f1451759 100644
--- a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js
+++ b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js
@@ -1,4 +1,5 @@
import { differenceInSeconds } from '~/lib/utils/datetime_utility';
+import { CLUSTER_AGENT_ERROR_MESSAGES } from '../constants';
export function generateServicePortsString(ports) {
if (!ports?.length) return '';
@@ -139,3 +140,9 @@ export function getCronJobsStatuses(items) {
...(ready.length && { ready }),
};
}
+
+export function humanizeClusterErrors(reason) {
+ const errorReason = reason.toLowerCase();
+ const errorMessage = CLUSTER_AGENT_ERROR_MESSAGES[errorReason];
+ return errorMessage || CLUSTER_AGENT_ERROR_MESSAGES.other;
+}
diff --git a/app/assets/javascripts/environments/new.js b/app/assets/javascripts/environments/new.js
index 5dd112ac5e6..652085b1f28 100644
--- a/app/assets/javascripts/environments/new.js
+++ b/app/assets/javascripts/environments/new.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { removeLastSlashInUrlPath } from '~/lib/utils/url_utility';
import NewEnvironment from './components/new_environment.vue';
import { apolloProvider } from './graphql/client';
@@ -10,12 +11,16 @@ export default (el) => {
return null;
}
- const { projectEnvironmentsPath, projectPath } = el.dataset;
+ const { projectEnvironmentsPath, projectPath, kasTunnelUrl } = el.dataset;
return new Vue({
el,
apolloProvider: apolloProvider(),
- provide: { projectEnvironmentsPath, projectPath },
+ provide: {
+ projectEnvironmentsPath,
+ projectPath,
+ kasTunnelUrl: removeLastSlashInUrlPath(kasTunnelUrl),
+ },
render(h) {
return h(NewEnvironment);
},
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index 0151dbb0bf7..bd8a7257d0c 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -6,9 +6,7 @@ import {
GlBadge,
GlAlert,
GlSprintf,
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
+ GlDisclosureDropdown,
} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import { createAlert, VARIANT_WARNING } from '~/alert';
@@ -38,9 +36,7 @@ export default {
GlBadge,
GlAlert,
GlSprintf,
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
+ GlDisclosureDropdown,
TimeAgoTooltip,
ErrorDetailsInfo,
TimelineChart,
@@ -167,6 +163,52 @@ export default {
showEmptyStacktraceAlert() {
return !this.loadingStacktrace && !this.showStacktrace && this.isStacktraceEmptyAlertVisible;
},
+ updateDropdownItems() {
+ return [
+ {
+ text: this.ignoreBtnLabel,
+ action: this.onIgnoreStatusUpdate,
+ extraAttrs: {
+ 'data-qa-selector': 'update_ignore_status_button',
+ },
+ },
+ {
+ text: this.resolveBtnLabel,
+ action: this.onResolveStatusUpdate,
+ extraAttrs: {
+ 'data-qa-selector': 'update_resolve_status_button',
+ },
+ },
+ ];
+ },
+ viewIssueDropdownItem() {
+ return {
+ text: __('View issue'),
+ href: this.error.gitlabIssuePath,
+ extraAttrs: {
+ 'data-qa-selector': 'view_issue_button',
+ },
+ };
+ },
+ createIssueDropdownItem() {
+ return {
+ text: __('Create issue'),
+ action: this.createIssue,
+ extraAttrs: {
+ 'data-qa-selector': 'create_issue_button',
+ },
+ };
+ },
+ dropdownItems() {
+ return [
+ { items: this.updateDropdownItems },
+ {
+ items: [
+ this.error.gitlabIssuePath ? this.viewIssueDropdownItem : this.createIssueDropdownItem,
+ ],
+ },
+ ];
+ },
},
watch: {
error(val) {
@@ -331,37 +373,14 @@ export default {
</gl-button>
</form>
</div>
- <gl-dropdown
- text="Options"
- class="gl-w-full gl-md-display-none"
- right
+ <gl-disclosure-dropdown
+ block
+ :toggle-text="__('Options')"
+ toggle-class="gl-md-display-none"
+ placement="right"
:disabled="issueUpdateInProgress"
- >
- <gl-dropdown-item
- data-qa-selector="update_ignore_status_button"
- @click="onIgnoreStatusUpdate"
- >{{ ignoreBtnLabel }}</gl-dropdown-item
- >
- <gl-dropdown-item
- data-qa-selector="update_resolve_status_button"
- @click="onResolveStatusUpdate"
- >{{ resolveBtnLabel }}</gl-dropdown-item
- >
- <gl-dropdown-divider />
- <gl-dropdown-item
- v-if="error.gitlabIssuePath"
- data-qa-selector="view_issue_button"
- :href="error.gitlabIssuePath"
- >{{ __('View issue') }}</gl-dropdown-item
- >
- <gl-dropdown-item
- v-if="!error.gitlabIssuePath"
- :loading="issueCreationInProgress"
- data-qa-selector="create_issue_button"
- @click="createIssue"
- >{{ __('Create issue') }}</gl-dropdown-item
- >
- </gl-dropdown>
+ :items="dropdownItems"
+ />
</div>
</div>
<div>
diff --git a/app/assets/javascripts/error_tracking/components/timeline_chart.vue b/app/assets/javascripts/error_tracking/components/timeline_chart.vue
index 51e0c900e4b..907a6c8557f 100644
--- a/app/assets/javascripts/error_tracking/components/timeline_chart.vue
+++ b/app/assets/javascripts/error_tracking/components/timeline_chart.vue
@@ -1,6 +1,6 @@
<script>
import { GlChart } from '@gitlab/ui/dist/charts';
-import { dataVizBlue500 } from '@gitlab/ui/scss_to_js/scss_variables';
+import { DATA_VIZ_BLUE_500 } from '@gitlab/ui/dist/tokens/js/tokens';
import { hexToRgba } from '@gitlab/ui/dist/utils/utils';
import { isNumber } from 'lodash';
import { formatDate } from '~/lib/utils/datetime/date_format_utility';
@@ -109,7 +109,7 @@ export default {
{
data: yData,
type: 'bar',
- itemStyle: { color: hexToRgba(dataVizBlue500, 0.5) },
+ itemStyle: { color: hexToRgba(DATA_VIZ_BLUE_500, 0.5) },
},
],
tooltip: {
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
index 37a0c679287..257c482cf1d 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
@@ -125,13 +125,13 @@ export default {
>
<div class="table-section section-10" role="gridcell">
<div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|ID') }}</div>
- <div class="table-mobile-content js-feature-flag-id">
+ <div class="table-mobile-content gl-text-left js-feature-flag-id">
{{ featureFlagIidText(featureFlag) }}
</div>
</div>
<div class="table-section section-10" role="gridcell">
<div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|Status') }}</div>
- <div class="table-mobile-content">
+ <div class="table-mobile-content gl-text-left">
<gl-toggle
v-if="featureFlag.update_path"
:value="featureFlag.active"
@@ -156,9 +156,11 @@ export default {
<div class="table-mobile-header" role="rowheader">
{{ s__('FeatureFlags|Feature flag') }}
</div>
- <div class="table-mobile-content d-flex flex-column js-feature-flag-title">
+ <div
+ class="table-mobile-content gl-text-left gl-display-flex flex-column js-feature-flag-title gl-mr-5"
+ >
<div class="gl-display-flex gl-align-items-center">
- <div class="feature-flag-name text-monospace text-truncate">
+ <div class="feature-flag-name text-monospace text-wrap gl-word-break-word">
{{ featureFlag.name }}
</div>
<div class="feature-flag-description">
@@ -178,7 +180,7 @@ export default {
{{ s__('FeatureFlags|Environment Specs') }}
</div>
<div
- class="table-mobile-content d-flex flex-wrap justify-content-end justify-content-md-start js-feature-flag-environments"
+ class="table-mobile-content gl-text-left d-flex flex-wrap justify-content-end justify-content-md-start js-feature-flag-environments"
>
<strategy-label
v-for="strategy in featureFlag.strategies"
diff --git a/app/assets/javascripts/frequent_items/store/mutations.js b/app/assets/javascripts/frequent_items/store/mutations.js
index 65f54e6ed05..9882bef444a 100644
--- a/app/assets/javascripts/frequent_items/store/mutations.js
+++ b/app/assets/javascripts/frequent_items/store/mutations.js
@@ -53,7 +53,7 @@ export default {
});
},
[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, results) {
- const rawItems = results.data ? results.data : results; // Api.groups returns array, Api.projects returns object
+ const rawItems = results.data;
Object.assign(state, {
items: rawItems.map((rawItem) => ({
id: rawItem.id,
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index b778e05c7b1..9e7006bb6e7 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -959,7 +959,7 @@ GfmAutoComplete.Emoji = {
return `<li>${escapedFieldValue}</li>`;
}
- return `<li>${escapedFieldValue} ${GfmAutoComplete.glEmojiTag(item.emoji.name)}</li>`;
+ return `<li>${GfmAutoComplete.glEmojiTag(item.emoji.name)} ${escapedFieldValue}</li>`;
},
filter(query) {
if (query.length === 0) {
diff --git a/app/assets/javascripts/gitlab_version_check/constants.js b/app/assets/javascripts/gitlab_version_check/constants.js
index 049397148ab..5cef29d73f3 100644
--- a/app/assets/javascripts/gitlab_version_check/constants.js
+++ b/app/assets/javascripts/gitlab_version_check/constants.js
@@ -1,4 +1,5 @@
import { helpPagePath } from '~/helpers/help_page_helper';
+import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
export const STATUS_TYPES = {
SUCCESS: 'success',
@@ -8,7 +9,7 @@ export const STATUS_TYPES = {
export const UPGRADE_DOCS_URL = helpPagePath('update/index');
-export const ABOUT_RELEASES_PAGE = 'https://about.gitlab.com/releases/categories/releases/';
+export const ABOUT_RELEASES_PAGE = `${PROMO_URL}/releases/categories/releases/`;
export const ALERT_MODAL_ID = 'security-patch-upgrade-alert-modal';
diff --git a/app/assets/javascripts/google_cloud/service_accounts/list.vue b/app/assets/javascripts/google_cloud/service_accounts/list.vue
index c9d9a9a3e8c..4ac788aafbe 100644
--- a/app/assets/javascripts/google_cloud/service_accounts/list.vue
+++ b/app/assets/javascripts/google_cloud/service_accounts/list.vue
@@ -1,6 +1,6 @@
<script>
import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf, GlTable } from '@gitlab/ui';
-import { setUrlParams } from '~/lib/utils/url_utility';
+import { setUrlParams, DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility';
import { __ } from '~/locale';
const GOOGLE_CONSOLE_URL = 'https://console.cloud.google.com/iam-admin/serviceaccounts';
@@ -49,6 +49,7 @@ export default {
},
},
GOOGLE_CONSOLE_URL,
+ secretsDocsLink: `${DOCS_URL_IN_EE_DIR}/ci/secrets/`,
};
</script>
@@ -86,7 +87,7 @@ export default {
<gl-alert class="gl-mt-5" :dismissible="false" variant="tip">
<gl-sprintf :message="$options.i18n.secretManagersDescription">
<template #docLink="{ content }">
- <gl-link href="https://docs.gitlab.com/ee/ci/secrets/">
+ <gl-link :href="$options.secretsDocsLink">
{{ content }}
</gl-link>
</template>
diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js
index 0a1a7a74d21..a9ae9a5af82 100644
--- a/app/assets/javascripts/google_tag_manager/index.js
+++ b/app/assets/javascripts/google_tag_manager/index.js
@@ -129,6 +129,9 @@ export const trackSaasTrialGroup = () => {
}
const form = document.querySelector('.js-saas-trial-group');
+
+ if (!form) return;
+
form.addEventListener('submit', () => {
pushEvent('saasTrialGroup');
});
diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js
index ae7676a3e9e..08733bbe620 100644
--- a/app/assets/javascripts/graphql_shared/issuable_client.js
+++ b/app/assets/javascripts/graphql_shared/issuable_client.js
@@ -2,10 +2,11 @@ import produce from 'immer';
import VueApollo from 'vue-apollo';
import { defaultDataIdFromObject } from '@apollo/client/core';
import { concatPagination } from '@apollo/client/utilities';
+import errorQuery from '~/boards/graphql/client/error.query.graphql';
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';
+import { WIDGET_TYPE_NOTES, WIDGET_TYPE_AWARD_EMOJI } from '~/work_items/constants';
import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
export const config = {
@@ -35,6 +36,15 @@ export const config = {
},
},
},
+ WorkItemWidgetAwardEmoji: {
+ fields: {
+ // If we add any key args, the awardEmoji field becomes awardEmoji({"first":10}) and
+ // kills any possibility to handle it on the widget level without hardcoding a string.
+ awardEmoji: {
+ keyArgs: false,
+ },
+ },
+ },
WorkItemWidgetProgress: {
fields: {
progress: {
@@ -67,10 +77,30 @@ 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) {
+ // We don't want to override existing notes or award emojis with empty widget on work item updates
+ if (
+ (incomingWidget?.type === WIDGET_TYPE_NOTES ||
+ incomingWidget?.type === WIDGET_TYPE_AWARD_EMOJI) &&
+ !context.variables.pageSize
+ ) {
return existingWidget;
}
+
+ // we want to concat next page of awardEmoji to the existing ones
+ if (incomingWidget?.type === WIDGET_TYPE_AWARD_EMOJI && context.variables.after) {
+ // concatPagination won't work because we were placing new widget here so we have to do this manually
+ return {
+ ...incomingWidget,
+ awardEmoji: {
+ ...incomingWidget.awardEmoji,
+ nodes: [
+ ...existingWidget.awardEmoji.nodes,
+ ...incomingWidget.awardEmoji.nodes,
+ ],
+ },
+ };
+ }
+
// 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
@@ -195,6 +225,13 @@ export const resolvers = {
});
return boardItem;
},
+ setError(_, { error }, { cache }) {
+ cache.writeQuery({
+ query: errorQuery,
+ data: { boardsAppError: error },
+ });
+ return error;
+ },
clientToggleListCollapsed(_, { list = {}, collapsed = false }) {
return {
list: {
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index ebfffdaaf50..c6fe16b13b5 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -114,9 +114,9 @@ export default {
showModal() {
this.isModalVisible = true;
},
- fetchGroups({ parentId, page, filterGroupsBy, sortBy, archived, updatePagination }) {
+ fetchGroups({ parentId, page, filterGroupsBy, sortBy, updatePagination }) {
return this.service
- .getGroups(parentId, page, filterGroupsBy, sortBy, archived)
+ .getGroups(parentId, page, filterGroupsBy, sortBy)
.then((res) => {
if (updatePagination) {
this.updatePagination(res.headers);
@@ -133,7 +133,6 @@ export default {
fetchAllGroups() {
const page = getParameterByName('page') || null;
const sortBy = getParameterByName('sort') || null;
- const archived = getParameterByName('archived') || null;
this.isLoading = true;
@@ -141,7 +140,6 @@ export default {
page,
filterGroupsBy: this.filterGroupsBy,
sortBy,
- archived,
updatePagination: true,
}).then((res) => {
this.isLoading = false;
@@ -160,14 +158,13 @@ export default {
this.updateGroups(res, Boolean(filterGroupsBy));
});
},
- fetchPage({ page, filterGroupsBy, sortBy, archived }) {
+ fetchPage({ page, filterGroupsBy, sortBy }) {
this.isLoading = true;
return this.fetchGroups({
page,
filterGroupsBy,
sortBy,
- archived,
updatePagination: true,
}).then((res) => {
this.isLoading = false;
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
index 5674e28f5da..d87190edfd2 100644
--- a/app/assets/javascripts/groups/components/item_stats.vue
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -36,6 +36,9 @@ export default {
<template>
<div class="stats gl-text-gray-500">
+ <div v-if="isProjectPendingRemoval">
+ <gl-badge class="gl-mr-2" variant="warning">{{ __('pending deletion') }}</gl-badge>
+ </div>
<item-stats-value
v-if="displayValue(item.subgroupCount)"
:title="__('Subgroups')"
@@ -65,9 +68,6 @@ export default {
css-class="project-stars"
icon-name="star"
/>
- <div v-if="isProjectPendingRemoval">
- <gl-badge variant="warning">{{ __('pending deletion') }}</gl-badge>
- </div>
<div v-if="isProject" class="last-updated">
<time-ago-tooltip :time="item.lastActivityAt" tooltip-placement="bottom" />
</div>
diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue
index 982dab45117..90a0582cc9f 100644
--- a/app/assets/javascripts/groups/components/overview_tabs.vue
+++ b/app/assets/javascripts/groups/components/overview_tabs.vue
@@ -3,13 +3,17 @@ import { GlTabs, GlTab, GlSearchBoxByType, GlSorting, GlSortingItem } from '@git
import { isString, debounce } from 'lodash';
import { __ } from '~/locale';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+import { markRaw } from '~/lib/utils/vue3compat/mark_raw';
import GroupsStore from '../store/groups_store';
import GroupsService from '../service/groups_service';
+import ArchivedProjectsService from '../service/archived_projects_service';
import {
ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
ACTIVE_TAB_SHARED,
ACTIVE_TAB_ARCHIVED,
+ SORTING_ITEM_NAME,
OVERVIEW_TABS_SORTING_ITEMS,
+ OVERVIEW_TABS_ARCHIVED_PROJECTS_SORTING_ITEMS,
} from '../constants';
import eventHub from '../event_hub';
import GroupsApp from './app.vue';
@@ -17,7 +21,6 @@ import SubgroupsAndProjectsEmptyState from './empty_states/subgroups_and_project
import SharedProjectsEmptyState from './empty_states/shared_projects_empty_state.vue';
import ArchivedProjectsEmptyState from './empty_states/archived_projects_empty_state.vue';
-const [SORTING_ITEM_NAME] = OVERVIEW_TABS_SORTING_ITEMS;
const MIN_SEARCH_LENGTH = 3;
export default {
@@ -32,32 +35,38 @@ export default {
SharedProjectsEmptyState,
ArchivedProjectsEmptyState,
},
- inject: ['endpoints', 'initialSort'],
+ inject: ['endpoints', 'initialSort', 'groupId'],
data() {
const tabs = [
{
title: this.$options.i18n[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS],
key: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
- emptyStateComponent: SubgroupsAndProjectsEmptyState,
+ emptyStateComponent: markRaw(SubgroupsAndProjectsEmptyState),
lazy: this.$route.name !== ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
- service: new GroupsService(this.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]),
+ service: new GroupsService(
+ this.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS],
+ this.initialSort,
+ ),
store: new GroupsStore({ showSchemaMarkup: true }),
+ sortingItems: OVERVIEW_TABS_SORTING_ITEMS,
},
{
title: this.$options.i18n[ACTIVE_TAB_SHARED],
key: ACTIVE_TAB_SHARED,
- emptyStateComponent: SharedProjectsEmptyState,
+ emptyStateComponent: markRaw(SharedProjectsEmptyState),
lazy: this.$route.name !== ACTIVE_TAB_SHARED,
- service: new GroupsService(this.endpoints[ACTIVE_TAB_SHARED]),
+ service: new GroupsService(this.endpoints[ACTIVE_TAB_SHARED], this.initialSort),
store: new GroupsStore(),
+ sortingItems: OVERVIEW_TABS_SORTING_ITEMS,
},
{
title: this.$options.i18n[ACTIVE_TAB_ARCHIVED],
key: ACTIVE_TAB_ARCHIVED,
- emptyStateComponent: ArchivedProjectsEmptyState,
+ emptyStateComponent: markRaw(ArchivedProjectsEmptyState),
lazy: this.$route.name !== ACTIVE_TAB_ARCHIVED,
- service: new GroupsService(this.endpoints[ACTIVE_TAB_ARCHIVED]),
+ service: new ArchivedProjectsService(this.groupId, this.initialSort),
store: new GroupsStore(),
+ sortingItems: OVERVIEW_TABS_ARCHIVED_PROJECTS_SORTING_ITEMS,
},
];
return {
@@ -79,15 +88,30 @@ export default {
mounted() {
this.search = this.$route.query?.filter || '';
- const sortQueryStringValue = this.$route.query?.sort || this.initialSort;
- const sort =
- OVERVIEW_TABS_SORTING_ITEMS.find((sortOption) =>
- [sortOption.asc, sortOption.desc].includes(sortQueryStringValue),
- ) || SORTING_ITEM_NAME;
+ const { sort, isAscending } = this.getActiveSort();
+
this.sort = sort;
- this.isAscending = sort.asc === sortQueryStringValue;
+ this.isAscending = isAscending;
},
methods: {
+ getActiveSort() {
+ const sortQueryStringValue = this.$route.query?.sort || this.initialSort;
+ const sort = this.activeTab.sortingItems.find((sortOption) =>
+ [sortOption.asc, sortOption.desc].includes(sortQueryStringValue),
+ );
+
+ if (!sort) {
+ return {
+ sort: SORTING_ITEM_NAME,
+ isAscending: true,
+ };
+ }
+
+ return {
+ sort,
+ isAscending: sort.asc === sortQueryStringValue,
+ };
+ },
handleTabInput(tabIndex) {
if (tabIndex === this.activeTabIndex) {
return;
@@ -105,7 +129,23 @@ export default {
? this.$route.params.group.split('/')
: this.$route.params.group;
- this.$router.push({ name: tab.key, params: { group: groupParam }, query: this.$route.query });
+ const { sort, isAscending } = this.getActiveSort();
+
+ this.sort = sort;
+ this.isAscending = isAscending;
+
+ const sortQuery = isAscending ? sort.asc : sort.desc;
+
+ const query = {
+ ...this.$route.query,
+ ...(this.$route.query?.sort && { sort: sortQuery }),
+ };
+
+ this.$router.push({
+ name: tab.key,
+ params: { group: groupParam },
+ query,
+ });
},
handleSearchOrSortChange() {
// Update query string
@@ -164,7 +204,6 @@ export default {
[ACTIVE_TAB_ARCHIVED]: __('Archived projects'),
searchPlaceholder: __('Search'),
},
- OVERVIEW_TABS_SORTING_ITEMS,
};
</script>
@@ -203,7 +242,7 @@ export default {
@sortDirectionChange="handleSortDirectionChange"
>
<gl-sorting-item
- v-for="sortingItem in $options.OVERVIEW_TABS_SORTING_ITEMS"
+ v-for="sortingItem in activeTab.sortingItems"
:key="sortingItem.label"
:active="sortingItem === sort"
@click="handleSortingItemClick(sortingItem)"
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index a5854632040..574ec8e4e49 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -25,25 +25,39 @@ export const ITEM_TYPE = {
GROUP: 'group',
};
+export const SORTING_ITEM_NAME = {
+ label: __('Name'),
+ asc: 'name_asc',
+ desc: 'name_desc',
+};
+
+export const SORTING_ITEM_CREATED = {
+ label: __('Created'),
+ asc: 'created_asc',
+ desc: 'created_desc',
+};
+
+export const SORTING_ITEM_UPDATED = {
+ label: __('Updated'),
+ asc: 'latest_activity_asc',
+ desc: 'latest_activity_desc',
+};
+
+export const SORTING_ITEM_STARS = {
+ label: __('Stars'),
+ asc: 'stars_asc',
+ desc: 'stars_desc',
+};
+
export const OVERVIEW_TABS_SORTING_ITEMS = [
- {
- label: __('Name'),
- asc: 'name_asc',
- desc: 'name_desc',
- },
- {
- label: __('Created'),
- asc: 'created_asc',
- desc: 'created_desc',
- },
- {
- label: __('Updated'),
- asc: 'latest_activity_asc',
- desc: 'latest_activity_desc',
- },
- {
- label: __('Stars'),
- asc: 'stars_asc',
- desc: 'stars_desc',
- },
+ SORTING_ITEM_NAME,
+ SORTING_ITEM_CREATED,
+ SORTING_ITEM_UPDATED,
+ SORTING_ITEM_STARS,
+];
+
+export const OVERVIEW_TABS_ARCHIVED_PROJECTS_SORTING_ITEMS = [
+ SORTING_ITEM_NAME,
+ SORTING_ITEM_CREATED,
+ SORTING_ITEM_UPDATED,
];
diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js
index ced5d76d8b9..b831ae7b9d6 100644
--- a/app/assets/javascripts/groups/init_overview_tabs.js
+++ b/app/assets/javascripts/groups/init_overview_tabs.js
@@ -40,6 +40,7 @@ export const initGroupOverviewTabs = () => {
const router = createRouter();
const {
+ groupId,
newSubgroupPath,
newProjectPath,
newSubgroupIllustration,
@@ -59,6 +60,7 @@ export const initGroupOverviewTabs = () => {
el,
router,
provide: {
+ groupId,
newSubgroupPath,
newProjectPath,
newSubgroupIllustration,
diff --git a/app/assets/javascripts/groups/service/archived_projects_service.js b/app/assets/javascripts/groups/service/archived_projects_service.js
new file mode 100644
index 00000000000..5ffa3f91b06
--- /dev/null
+++ b/app/assets/javascripts/groups/service/archived_projects_service.js
@@ -0,0 +1,56 @@
+import Api from '~/api';
+
+export default class ArchivedProjectsService {
+ constructor(groupId, initialSort) {
+ this.groupId = groupId;
+ this.initialSort = initialSort;
+ }
+
+ async getGroups(parentId, page, query, sortParam) {
+ const supportedOrderBy = {
+ name: 'name',
+ created: 'created_at',
+ latest_activity: 'last_activity_at',
+ };
+
+ const [, orderBy, sort] = (sortParam || this.initialSort)?.match(/(\w+)_(asc|desc)/) || [];
+
+ const { data: projects, headers } = await Api.groupProjects(this.groupId, query, {
+ archived: true,
+ page,
+ order_by: supportedOrderBy[orderBy],
+ sort,
+ });
+
+ return {
+ data: projects.map((project) => {
+ return {
+ id: project.id,
+ name: project.name,
+ full_name: project.name_with_namespace,
+ markdown_description: project.description_html,
+ visibility: project.visibility,
+ avatar_url: project.avatar_url,
+ relative_path: `/${project.path_with_namespace}`,
+ edit_path: null,
+ leave_path: null,
+ can_edit: false,
+ can_leave: false,
+ can_remove: false,
+ type: 'project',
+ permission: null,
+ children: [],
+ parent_id: project.namespace.id,
+ project_count: 0,
+ subgroup_count: 0,
+ number_users_with_delimiter: 0,
+ star_count: project.star_count,
+ updated_at: project.updated_at,
+ marked_for_deletion: project.marked_for_deletion_at !== null,
+ last_activity_at: project.last_activity_at,
+ };
+ }),
+ headers,
+ };
+ }
+}
diff --git a/app/assets/javascripts/groups/service/groups_service.js b/app/assets/javascripts/groups/service/groups_service.js
index 790b581a7c0..28d203bc9c6 100644
--- a/app/assets/javascripts/groups/service/groups_service.js
+++ b/app/assets/javascripts/groups/service/groups_service.js
@@ -1,11 +1,12 @@
import axios from '~/lib/utils/axios_utils';
export default class GroupsService {
- constructor(endpoint) {
+ constructor(endpoint, initialSort) {
this.endpoint = endpoint;
+ this.initialSort = initialSort;
}
- getGroups(parentId, page, filterGroups, sort, archived) {
+ getGroups(parentId, page, filterGroups, sort) {
const params = {};
if (parentId) {
@@ -20,12 +21,8 @@ export default class GroupsService {
params.filter = filterGroups;
}
- if (sort) {
- params.sort = sort;
- }
-
- if (archived) {
- params.archived = archived;
+ if (sort || this.initialSort) {
+ params.sort = sort || this.initialSort;
}
}
diff --git a/app/assets/javascripts/groups/settings/init_access_dropdown.js b/app/assets/javascripts/groups/settings/init_access_dropdown.js
index 24419280fc0..4da38e0e641 100644
--- a/app/assets/javascripts/groups/settings/init_access_dropdown.js
+++ b/app/assets/javascripts/groups/settings/init_access_dropdown.js
@@ -4,7 +4,7 @@ import AccessDropdown from './components/access_dropdown.vue';
export const initAccessDropdown = (el) => {
if (!el) {
- return false;
+ return null;
}
const { label, disabled, preselectedItems } = el.dataset;
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index 8c7612f37ff..3cb0963e561 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -225,7 +225,7 @@ export default {
v-model="searchText"
role="searchbox"
class="gl-z-index-1"
- data-qa-selector="global_search_input"
+ data-testid="global_search_input"
autocomplete="off"
:placeholder="$options.i18n.SEARCH_GITLAB"
:aria-activedescendant="currentFocusedId"
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
index 8962bb76926..edc6cc3dcdc 100644
--- a/app/assets/javascripts/ide/components/ide_status_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -105,6 +105,7 @@ export default {
:title="lastCommit.message"
:href="getCommitPath(lastCommit.short_id)"
class="commit-sha"
+ data-testid="commit-sha-content"
data-qa-selector="commit_sha_content"
>{{ lastCommit.short_id }}</a
>
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 fe50cb77eb8..cd07e9fbdd9 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
@@ -664,6 +664,7 @@ export default {
<gl-search-box-by-click
class="gl-ml-auto"
+ data-testid="filter-groups"
:placeholder="s__('BulkImport|Filter by source group')"
@submit="filter = $event"
@clear="filter = ''"
diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue
index 0e9781d77fe..1369deae3f9 100644
--- a/app/assets/javascripts/invite_members/components/group_select.vue
+++ b/app/assets/javascripts/invite_members/components/group_select.vue
@@ -1,29 +1,25 @@
<script>
-import {
- GlAvatarLabeled,
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlSearchBoxByType,
-} from '@gitlab/ui';
+import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui';
import { debounce } from 'lodash';
import { s__ } from '~/locale';
import { getGroups, getDescendentGroups } from '~/rest_api';
+import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
import { SEARCH_DELAY, GROUP_FILTERS } from '../constants';
export default {
name: 'GroupSelect',
components: {
GlAvatarLabeled,
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlSearchBoxByType,
+ GlCollapsibleListbox,
},
model: {
prop: 'selectedGroup',
},
props: {
+ selectedGroup: {
+ type: Object,
+ required: true,
+ },
groupsFilter: {
type: String,
required: false,
@@ -43,40 +39,43 @@ export default {
return {
isFetching: false,
groups: [],
- selectedGroup: {},
searchTerm: '',
+ pagination: {},
+ infiniteScrollLoading: false,
};
},
computed: {
- selectedGroupName() {
+ toggleText() {
return this.selectedGroup.name || this.$options.i18n.dropdownText;
},
isFetchResultEmpty() {
return this.groups.length === 0;
},
- },
- watch: {
- searchTerm() {
- this.retrieveGroups();
+ infiniteScroll() {
+ return Boolean(this.pagination.nextPage);
},
},
mounted() {
this.retrieveGroups();
},
methods: {
- retrieveGroups: debounce(function debouncedRetrieveGroups() {
+ retrieveGroups: debounce(async function debouncedRetrieveGroups() {
this.isFetching = true;
- return this.fetchGroups()
- .then((response) => {
- this.groups = this.processGroups(response);
- this.isFetching = false;
- })
- .catch(() => {
- this.isFetching = false;
- });
+
+ try {
+ const response = await this.fetchGroups();
+ this.pagination = this.processPagination(response);
+ this.groups = this.processGroups(response);
+ } catch {
+ this.onApiError();
+ } finally {
+ this.isFetching = false;
+ }
}, SEARCH_DELAY),
- processGroups(response) {
- const rawGroups = response.map((group) => ({
+ processGroups({ data }) {
+ const rawGroups = data.map((group) => ({
+ // `value` is needed for `GlCollapsibleListbox`
+ value: group.id,
id: group.id,
name: group.full_name,
path: group.path,
@@ -85,31 +84,56 @@ export default {
return this.filterOutInvalidGroups(rawGroups);
},
+ processPagination({ headers }) {
+ return parseIntPagination(normalizeHeaders(headers));
+ },
filterOutInvalidGroups(groups) {
return groups.filter((group) => this.invalidGroups.indexOf(group.id) === -1);
},
- selectGroup(group) {
- this.selectedGroup = group;
-
- this.$emit('input', this.selectedGroup);
+ onSelect(id) {
+ this.$emit('input', this.groups.find((group) => group.value === id) || {});
},
- fetchGroups() {
+ onSearch(searchTerm) {
+ this.searchTerm = searchTerm;
+ this.retrieveGroups();
+ },
+ fetchGroups(options = {}) {
+ const combinedOptions = {
+ ...this.$options.defaultFetchOptions,
+ ...options,
+ };
+
switch (this.groupsFilter) {
case GROUP_FILTERS.DESCENDANT_GROUPS:
- return getDescendentGroups(
- this.parentGroupId,
- this.searchTerm,
- this.$options.defaultFetchOptions,
- );
+ return getDescendentGroups(this.parentGroupId, this.searchTerm, combinedOptions);
default:
- return getGroups(this.searchTerm, this.$options.defaultFetchOptions);
+ return getGroups(this.searchTerm, combinedOptions);
}
},
+ async onBottomReached() {
+ this.infiniteScrollLoading = true;
+
+ try {
+ const response = await this.fetchGroups({ page: this.pagination.page + 1 });
+ this.pagination = this.processPagination(response);
+ this.groups.push(...this.processGroups(response));
+ } catch {
+ this.onApiError();
+ } finally {
+ this.infiniteScrollLoading = false;
+ }
+ },
+ onApiError() {
+ this.$emit('error', this.$options.i18n.errorMessage);
+ },
},
i18n: {
dropdownText: s__('GroupSelect|Select a group'),
searchPlaceholder: s__('GroupSelect|Search groups'),
emptySearchResult: s__('GroupSelect|No matching results'),
+ errorMessage: s__(
+ 'GroupSelect|An error occurred fetching the groups. Please refresh the page to try again.',
+ ),
},
defaultFetchOptions: {
exclude_internal: true,
@@ -120,37 +144,34 @@ export default {
</script>
<template>
<div>
- <gl-dropdown
+ <gl-collapsible-listbox
data-testid="group-select-dropdown"
- :text="selectedGroupName"
+ :selected="selectedGroup.value"
+ :items="groups"
+ :toggle-text="toggleText"
+ searchable
+ :search-placeholder="$options.i18n.searchPlaceholder"
block
- toggle-class="gl-mb-2"
- menu-class="gl-w-full!"
+ fluid-width
+ is-check-centered
+ :searching="isFetching"
+ :no-results-text="$options.i18n.emptySearchResult"
+ :infinite-scroll="infiniteScroll"
+ :infinite-scroll-loading="infiniteScrollLoading"
+ :total-items="pagination.total"
+ @bottom-reached="onBottomReached"
+ @select="onSelect"
+ @search="onSearch"
>
- <gl-search-box-by-type
- v-model="searchTerm"
- :is-loading="isFetching"
- :placeholder="$options.i18n.searchPlaceholder"
- data-qa-selector="group_select_dropdown_search_field"
- />
- <gl-dropdown-item
- v-for="group in groups"
- :key="group.id"
- :name="group.name"
- data-qa-selector="group_select_dropdown_item"
- @click="selectGroup(group)"
- >
+ <template #list-item="{ item }">
<gl-avatar-labeled
- :label="group.name"
- :src="group.avatarUrl"
- :entity-id="group.id"
- :entity-name="group.name"
+ :label="item.name"
+ :src="item.avatarUrl"
+ :entity-id="item.value"
+ :entity-name="item.name"
:size="32"
/>
- </gl-dropdown-item>
- <gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message">
- <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
- </gl-dropdown-text>
- </gl-dropdown>
+ </template>
+ </gl-collapsible-listbox>
</div>
</template>
diff --git a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
index cc95027f0db..66d4a9ccc07 100644
--- a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
@@ -209,7 +209,6 @@ export default {
:invalid-feedback="invalidFeedbackMessage"
:state="validationState"
data-testid="form-group"
- label-cols="auto"
label-class="gl-pt-3!"
:label="$options.i18n.projectLabel"
:label-for="$options.projectSelectLabelId"
diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
index 51355baef99..91dbd86418c 100644
--- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
@@ -1,4 +1,6 @@
<script>
+import * as Sentry from '@sentry/browser';
+import { GlAlert } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import Api from '~/api';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
@@ -19,6 +21,7 @@ export default {
GroupSelect,
InviteModalBase,
InviteGroupNotification,
+ GlAlert,
},
props: {
id: {
@@ -83,6 +86,7 @@ export default {
isLoading: false,
modalId: uniqueId('invite-groups-modal-'),
groupToBeSharedWith: {},
+ groupSelectError: '',
};
},
computed: {
@@ -165,6 +169,10 @@ export default {
clearValidation() {
this.invalidFeedbackMessage = '';
},
+ onGroupSelectError(error) {
+ this.groupSelectError = error;
+ Sentry.captureException(error);
+ },
},
labels: GROUP_MODAL_LABELS,
};
@@ -197,6 +205,9 @@ export default {
:notification-link="$options.labels[inviteTo].notificationLink"
class="gl-mb-5"
/>
+ <gl-alert v-if="groupSelectError" class="gl-mb-5" variant="danger" :dismissible="false">{{
+ groupSelectError
+ }}</gl-alert>
</template>
<template #select>
@@ -206,6 +217,7 @@ export default {
:parent-group-id="groupSelectParentId"
:invalid-groups="invalidGroups"
@input="clearValidation"
+ @error="onGroupSelectError"
/>
</template>
</invite-modal-base>
diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue
index e0bfa1111e8..8493787f075 100644
--- a/app/assets/javascripts/invite_members/components/members_token_select.vue
+++ b/app/assets/javascripts/invite_members/components/members_token_select.vue
@@ -59,6 +59,7 @@ export default {
return {
loading: false,
query: '',
+ originalInput: '',
users: [],
selectedTokens: [],
hasBeenFocused: false,
@@ -67,9 +68,9 @@ export default {
},
computed: {
emailIsValid() {
- const regex = /.+@/;
+ const regex = /^\S+@\S+$/;
- return this.query.match(regex) !== null;
+ return this.originalInput.match(regex) !== null;
},
placeholderText() {
if (this.selectedTokens.length === 0) {
@@ -116,6 +117,7 @@ export default {
methods: {
handleTextInput(inputQuery) {
this.hideDropdownWithNoItems = false;
+ this.originalInput = inputQuery;
this.query = inputQuery.trim();
this.loading = true;
this.retrieveUsers();
diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue
index 799c0a18444..0d7d0f020dd 100644
--- a/app/assets/javascripts/issuable/components/status_box.vue
+++ b/app/assets/javascripts/issuable/components/status_box.vue
@@ -4,7 +4,13 @@ import Vue from 'vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { fetchPolicies } from '~/lib/graphql';
import { __ } from '~/locale';
-import { STATUS_CLOSED, STATUS_OPEN, TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
+import {
+ STATUS_CLOSED,
+ STATUS_OPEN,
+ TYPE_ISSUE,
+ TYPE_MERGE_REQUEST,
+ TYPE_EPIC,
+} from '~/issues/constants';
export const badgeState = Vue.observable({
state: '',
@@ -18,17 +24,22 @@ const CLASSES = {
merged: 'issuable-status-badge-merged',
};
-const ISSUE_ICONS = {
- opened: 'issues',
- locked: 'issues',
- closed: 'issue-closed',
-};
-
-const MERGE_REQUEST_ICONS = {
- opened: 'merge-request-open',
- locked: 'merge-request-open',
- closed: 'merge-request-close',
- merged: 'merge',
+const ICONS = {
+ [TYPE_EPIC]: {
+ opened: 'epic',
+ closed: 'epic-closed',
+ },
+ [TYPE_ISSUE]: {
+ opened: 'issues',
+ locked: 'issues',
+ closed: 'issue-closed',
+ },
+ [TYPE_MERGE_REQUEST]: {
+ opened: 'merge-request-open',
+ locked: 'merge-request-open',
+ closed: 'merge-request-close',
+ merged: 'merge',
+ },
};
const STATUS = {
@@ -91,10 +102,8 @@ export default {
return STATUS[this.state];
},
badgeIcon() {
- if (this.issuableType === TYPE_ISSUE) {
- return ISSUE_ICONS[this.state];
- }
- return MERGE_REQUEST_ICONS[this.state];
+ const type = this.issuableType || TYPE_MERGE_REQUEST;
+ return ICONS[type][this.state];
},
},
created() {
@@ -126,7 +135,7 @@ export default {
<template>
<gl-badge
- class="issuable-status-badge gl-mr-3"
+ class="issuable-status-badge gl-mr-3 gl-align-self-center"
:class="badgeClass"
:variant="badgeVariant"
:aria-label="badgeText"
diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js
index a1525ad2bec..1c1acddb90b 100644
--- a/app/assets/javascripts/issuable/issuable_form.js
+++ b/app/assets/javascripts/issuable/issuable_form.js
@@ -7,6 +7,9 @@ import { queryToObject, objectToQuery } from '~/lib/utils/url_utility';
import UsersSelect from '~/users_select';
import ZenMode from '~/zen_mode';
import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection';
+import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking';
+import { EDITING_MODE_CONTENT_EDITOR } from '~/vue_shared/constants';
+import { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } from '~/notes/constants';
const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
const MR_TARGET_BRANCH = 'merge_request[target_branch]';
@@ -47,6 +50,13 @@ function getFallbackKey() {
return ['autosave', document.location.pathname, searchTerm].join('/');
}
+function getIssuableType() {
+ if (document.location.pathname.includes('merge_requests')) return MERGE_REQUEST_NOTEABLE_TYPE;
+ if (document.location.pathname.includes('issues')) return ISSUE_NOTEABLE_TYPE;
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return 'Other';
+}
+
export default class IssuableForm {
static addAutosave(map, id, element, searchTerm, fallbackKey) {
if (!element) return;
@@ -144,6 +154,11 @@ export default class IssuableForm {
async handleSubmit(event) {
event.preventDefault();
+ trackSavedUsingEditor(
+ localStorage.getItem('gl-markdown-editor-mode') === EDITING_MODE_CONTENT_EDITOR,
+ getIssuableType(),
+ );
+
const form = event.target;
const descriptionText = this.descriptionField().val();
diff --git a/app/assets/javascripts/issuable/issuable_label_selector.js b/app/assets/javascripts/issuable/issuable_label_selector.js
index b4e277a0b31..fc6d850c341 100644
--- a/app/assets/javascripts/issuable/issuable_label_selector.js
+++ b/app/assets/javascripts/issuable/issuable_label_selector.js
@@ -45,6 +45,7 @@ export default () => {
labelsManagePath,
variant: VARIANT_EMBEDDED,
workspaceType: WORKSPACE_PROJECT,
+ toggleAttrs: { 'data-testid': 'issuable_label_dropdown' },
},
render(createElement) {
return createElement(IssuableLabelSelector);
diff --git a/app/assets/javascripts/issuable/popover/components/issue_popover.vue b/app/assets/javascripts/issuable/popover/components/issue_popover.vue
index 55fb3958e82..044a1bba7ad 100644
--- a/app/assets/javascripts/issuable/popover/components/issue_popover.vue
+++ b/app/assets/javascripts/issuable/popover/components/issue_popover.vue
@@ -28,7 +28,7 @@ export default {
type: HTMLAnchorElement,
required: true,
},
- projectPath: {
+ namespacePath: {
type: String,
required: true,
},
@@ -65,10 +65,10 @@ export default {
query,
update: (data) => data.project.issue,
variables() {
- const { projectPath, iid } = this;
+ const { namespacePath, iid } = this;
return {
- projectPath,
+ projectPath: namespacePath,
iid,
};
},
@@ -100,7 +100,7 @@ export default {
<!-- eslint-disable @gitlab/vue-require-i18n-strings -->
<div>
<work-item-type-icon v-if="!$apollo.queries.issue.loading" :work-item-type="issue.type" />
- <span class="gl-text-secondary">{{ `${projectPath}#${iid}` }}</span>
+ <span class="gl-text-secondary">{{ `${namespacePath}#${iid}` }}</span>
</div>
<!-- eslint-enable @gitlab/vue-require-i18n-strings -->
diff --git a/app/assets/javascripts/issuable/popover/components/mr_popover.vue b/app/assets/javascripts/issuable/popover/components/mr_popover.vue
index af93430963e..e2c2181684f 100644
--- a/app/assets/javascripts/issuable/popover/components/mr_popover.vue
+++ b/app/assets/javascripts/issuable/popover/components/mr_popover.vue
@@ -19,7 +19,7 @@ export default {
type: HTMLAnchorElement,
required: true,
},
- projectPath: {
+ namespacePath: {
type: String,
required: true,
},
@@ -76,10 +76,10 @@ export default {
query,
update: (data) => data.project.mergeRequest,
variables() {
- const { projectPath, iid } = this;
+ const { namespacePath, iid } = this;
return {
- projectPath,
+ projectPath: namespacePath,
iid,
};
},
@@ -108,7 +108,7 @@ export default {
<h5 v-if="!$apollo.queries.mergeRequest.loading" class="my-2">{{ title }}</h5>
<!-- eslint-disable @gitlab/vue-require-i18n-strings -->
<div class="gl-text-secondary">
- {{ `${projectPath}!${iid}` }}
+ {{ `${namespacePath}!${iid}` }}
</div>
<!-- eslint-enable @gitlab/vue-require-i18n-strings -->
</div>
diff --git a/app/assets/javascripts/issuable/popover/index.js b/app/assets/javascripts/issuable/popover/index.js
index 9430419685b..58f015fe40e 100644
--- a/app/assets/javascripts/issuable/popover/index.js
+++ b/app/assets/javascripts/issuable/popover/index.js
@@ -4,7 +4,7 @@ import createDefaultClient from '~/lib/graphql';
import IssuePopover from './components/issue_popover.vue';
import MRPopover from './components/mr_popover.vue';
-const componentsByReferenceType = {
+export const componentsByReferenceTypeMap = {
issue: IssuePopover,
work_item: IssuePopover,
merge_request: MRPopover,
@@ -26,9 +26,10 @@ const popoverMountedAttr = 'data-popover-mounted';
* Adds a MergeRequestPopover component to the body, hands over as much data as the target element has in data attributes.
* loads based on data-project-path and data-iid more data about an MR from the API and sets it on the popover
*/
-const handleIssuablePopoverMount = ({
+export const handleIssuablePopoverMount = ({
+ componentsByReferenceType = componentsByReferenceTypeMap,
apolloProvider,
- projectPath,
+ namespacePath,
title,
iid,
referenceType,
@@ -42,7 +43,7 @@ const handleIssuablePopoverMount = ({
new PopoverComponent({
propsData: {
target,
- projectPath,
+ namespacePath,
iid,
cachedTitle: title,
},
@@ -53,7 +54,7 @@ const handleIssuablePopoverMount = ({
}, 200); // 200ms delay so not every mouseover triggers Popover + API Call
};
-export default (elements) => {
+export default (elements, issuablePopoverMount = handleIssuablePopoverMount) => {
if (elements.length > 0) {
Vue.use(VueApollo);
@@ -63,15 +64,16 @@ export default (elements) => {
const listenerAddedAttr = 'data-popover-listener-added';
elements.forEach((el) => {
- const { projectPath, iid, referenceType } = el.dataset;
+ const { projectPath, groupPath, iid, referenceType } = el.dataset;
const title = el.dataset.mrTitle || el.title;
+ const namespacePath = groupPath || projectPath;
- if (!el.getAttribute(listenerAddedAttr) && projectPath && title && iid && referenceType) {
+ if (!el.getAttribute(listenerAddedAttr) && namespacePath && title && iid && referenceType) {
el.addEventListener('mouseenter', ({ target }) => {
if (!el.getAttribute(popoverMountedAttr)) {
- handleIssuablePopoverMount({
+ issuablePopoverMount({
apolloProvider,
- projectPath,
+ namespacePath,
title,
iid,
referenceType,
diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js
index 444ee704521..0a1a1324d7d 100644
--- a/app/assets/javascripts/issues/constants.js
+++ b/app/assets/javascripts/issues/constants.js
@@ -31,4 +31,7 @@ export const issuableStatusText = {
export const IssuableTypeText = {
[TYPE_ISSUE]: __('issue'),
[TYPE_MERGE_REQUEST]: __('merge request'),
+ [TYPE_ALERT]: __('alert'),
+ [TYPE_INCIDENT]: __('incident'),
+ [TYPE_TEST_CASE]: __('test case'),
};
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 14fe88b8f61..eb73f8e0182 100644
--- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
+++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
@@ -81,6 +81,7 @@ export default {
},
inject: [
'autocompleteAwardEmojisPath',
+ 'autocompleteUsersPath',
'calendarPath',
'dashboardLabelsPath',
'dashboardMilestonesPath',
@@ -233,6 +234,7 @@ export default {
title: TOKEN_TITLE_ASSIGNEE,
icon: 'user',
token: UserToken,
+ dataType: 'user',
operators: OPERATORS_IS_NOT_OR,
fetchUsers: this.fetchUsers,
preloadedUsers,
@@ -243,6 +245,7 @@ export default {
title: TOKEN_TITLE_AUTHOR,
icon: 'pencil',
token: UserToken,
+ dataType: 'user',
operators: OPERATORS_IS_NOT_OR,
fetchUsers: this.fetchUsers,
defaultUsers: [],
@@ -382,7 +385,9 @@ export default {
});
},
fetchUsers(search) {
- return axios.get('/-/autocomplete/users.json', { params: { active: true, search } });
+ return axios.get(this.autocompleteUsersPath, {
+ params: { active: true, search },
+ });
},
getStatus(issue) {
if (issue.state === STATUS_CLOSED && issue.moved) {
diff --git a/app/assets/javascripts/issues/dashboard/index.js b/app/assets/javascripts/issues/dashboard/index.js
index 999f07781b2..74633b251b2 100644
--- a/app/assets/javascripts/issues/dashboard/index.js
+++ b/app/assets/javascripts/issues/dashboard/index.js
@@ -15,6 +15,7 @@ export async function mountIssuesDashboardApp() {
const {
autocompleteAwardEmojisPath,
+ autocompleteUsersPath,
calendarPath,
dashboardLabelsPath,
dashboardMilestonesPath,
@@ -38,6 +39,7 @@ export async function mountIssuesDashboardApp() {
}),
provide: {
autocompleteAwardEmojisPath,
+ autocompleteUsersPath,
calendarPath,
dashboardLabelsPath,
dashboardMilestonesPath,
diff --git a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
index 3f29fc66abb..9f7fca0ceca 100644
--- a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
+++ b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
@@ -28,6 +28,7 @@ export default {
'newProjectPath',
'showNewIssueLink',
'signInPath',
+ 'groupId',
],
props: {
currentTabCount: {
@@ -95,6 +96,7 @@ export default {
:query="$options.searchProjectsQuery"
:query-variables="newIssueDropdownQueryVariables"
:extract-projects="extractProjects"
+ :group-id="groupId"
/>
</template>
</gl-empty-state>
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 83b0bcebe67..f7693dd7102 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -166,6 +166,7 @@ export default {
'releasesPath',
'rssPath',
'showNewIssueLink',
+ 'groupId',
],
props: {
eeSearchTokens: {
@@ -365,6 +366,7 @@ export default {
title: TOKEN_TITLE_AUTHOR,
icon: 'pencil',
token: UserToken,
+ dataType: 'user',
defaultUsers: [],
operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT,
fetchUsers: this.fetchUsers,
@@ -376,6 +378,7 @@ export default {
title: TOKEN_TITLE_ASSIGNEE,
icon: 'user',
token: UserToken,
+ dataType: 'user',
operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT,
fetchUsers: this.fetchUsers,
recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`,
@@ -893,6 +896,7 @@ export default {
:query="$options.searchProjectsQuery"
:query-variables="newIssueDropdownQueryVariables"
:extract-projects="extractProjects"
+ :group-id="groupId"
/>
<gl-disclosure-dropdown
v-gl-tooltip.hover="$options.i18n.actionsLabel"
diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js
index a97b59c1e4f..d1b45294026 100644
--- a/app/assets/javascripts/issues/list/index.js
+++ b/app/assets/javascripts/issues/list/index.js
@@ -94,6 +94,7 @@ export async function mountIssuesListApp() {
rssPath,
showNewIssueLink,
signInPath,
+ groupId = '',
} = el.dataset;
return new Vue({
@@ -153,6 +154,7 @@ export async function mountIssuesListApp() {
markdownHelpPath,
quickActionsHelpPath,
resetPath,
+ groupId,
},
render: (createComponent) => createComponent(IssuesListApp),
});
diff --git a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
index 8490ffd33cd..cbec10b4ebe 100644
--- a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
+++ b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
@@ -65,61 +65,65 @@ export default {
<template>
<div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)">
- <div class="card card-slim gl-mt-5 gl-mb-0 gl-bg-gray-10">
- <div class="card-header gl-px-5 gl-py-4 gl-bg-white">
- <div
- class="card-title gl-relative gl-display-flex gl-flex-wrap gl-align-items-center gl-line-height-20 gl-font-weight-bold gl-m-0"
- >
+ <div class="gl-new-card">
+ <div class="gl-new-card-header gl-flex-direction-column">
+ <div class="gl-new-card-title-wrapper">
<gl-link
class="anchor gl-absolute gl-text-decoration-none"
href="#related-merge-requests"
aria-labelledby="related-merge-requests"
/>
- <h3 id="related-merge-requests" class="gl-font-base gl-m-0">
+ <h3 id="related-merge-requests" class="gl-new-card-title">
{{ __('Related merge requests') }}
</h3>
- <template v-if="totalCount">
- <gl-icon name="merge-request" class="gl-ml-3 gl-mr-2 gl-text-gray-500" />
- <span data-testid="count" class="gl-text-gray-500">{{ totalCount }}</span>
- </template>
- <p
- v-if="hasClosingMergeRequest && !isFetchingMergeRequests"
- class="gl-font-sm gl-font-weight-normal gl-flex-basis-full gl-mb-0 gl-text-gray-500"
- >
- {{ closingMergeRequestsText }}
- </p>
+ <div class="gl-new-card-count">
+ <template v-if="totalCount">
+ <gl-icon name="merge-request" class="gl-mr-2" />
+ <span data-testid="count">{{ totalCount }}</span>
+ </template>
+ </div>
</div>
- </div>
- <gl-loading-icon
- v-if="isFetchingMergeRequests"
- size="sm"
- label="Fetching related merge requests"
- class="gl-py-4"
- />
- <ul v-else class="content-list related-items-list gl-px-4! gl-py-3!">
- <li
- v-for="mr in mergeRequests"
- :key="mr.id"
- class="list-item gl-m-0! gl-p-0! gl-border-b-0!"
+ <p
+ v-if="hasClosingMergeRequest && !isFetchingMergeRequests"
+ class="gl-new-card-description"
>
- <related-issuable-item
- :id-key="mr.id"
- :display-reference="mr.reference"
- :title="mr.title"
- :milestone="mr.milestone"
- :assignees="getAssignees(mr)"
- :created-at="mr.created_at"
- :closed-at="mr.closed_at"
- :merged-at="mr.merged_at"
- :path="mr.web_url"
- :state="mr.state"
- :is-merge-request="true"
- :pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status"
- path-id-separator="!"
- class="gl-mx-n2"
+ {{ closingMergeRequestsText }}
+ </p>
+ </div>
+ <div class="gl-new-card-body">
+ <div class="gl-new-card-content">
+ <gl-loading-icon
+ v-if="isFetchingMergeRequests"
+ size="sm"
+ label="Fetching related merge requests"
+ class="gl-py-2"
/>
- </li>
- </ul>
+ <ul class="content-list related-items-list">
+ <li
+ v-for="mr in mergeRequests"
+ :key="mr.id"
+ class="list-item gl-m-0! gl-p-0! gl-border-b-0!"
+ >
+ <related-issuable-item
+ :id-key="mr.id"
+ :display-reference="mr.reference"
+ :title="mr.title"
+ :milestone="mr.milestone"
+ :assignees="getAssignees(mr)"
+ :created-at="mr.created_at"
+ :closed-at="mr.closed_at"
+ :merged-at="mr.merged_at"
+ :path="mr.web_url"
+ :state="mr.state"
+ :is-merge-request="true"
+ :pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status"
+ path-id-separator="!"
+ class="gl-mx-n2"
+ />
+ </li>
+ </ul>
+ </div>
+ </div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue
index c8ea8fb7ab2..a1463d0e911 100644
--- a/app/assets/javascripts/issues/show/components/fields/description.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description.vue
@@ -1,14 +1,13 @@
<script>
import { __ } from '~/locale';
-import MarkdownField from '~/vue_shared/components/markdown/field.vue';
-import { helpPagePath } from '~/helpers/help_page_helper';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking';
+import { ISSUE_NOTEABLE_TYPE } from '~/notes/constants';
import updateMixin from '../../mixins/update';
export default {
components: {
- MarkdownField,
MarkdownEditor,
},
mixins: [updateMixin, glFeaturesFlagMixin()],
@@ -47,8 +46,8 @@ export default {
};
},
computed: {
- quickActionsDocsPath() {
- return helpPagePath('user/project/quick_actions');
+ autocompleteDataSources() {
+ return gl.GfmAutoComplete?.dataSources;
},
},
mounted() {
@@ -58,6 +57,10 @@ export default {
focus() {
this.$refs.textarea?.focus();
},
+ saveIssuable() {
+ trackSavedUsingEditor(this.$refs.markdownEditor.isContentEditorActive, ISSUE_NOTEABLE_TYPE);
+ this.updateIssuable();
+ },
},
};
</script>
@@ -66,45 +69,21 @@ export default {
<div class="common-note-form">
<label class="sr-only" for="issue-description">{{ __('Description') }}</label>
<markdown-editor
- v-if="glFeatures.contentEditorOnIssues"
+ ref="markdownEditor"
+ :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)"
class="gl-mt-3"
:value="value"
:render-markdown-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:form-field-props="formFieldProps"
- :quick-actions-docs-path="quickActionsDocsPath"
:enable-autocomplete="enableAutocomplete"
+ :autocomplete-data-sources="autocompleteDataSources"
supports-quick-actions
autofocus
+ data-qa-selector="description_field"
@input="$emit('input', $event)"
- @keydown.meta.enter="updateIssuable"
- @keydown.ctrl.enter="updateIssuable"
+ @keydown.meta.enter="saveIssuable"
+ @keydown.ctrl.enter="saveIssuable"
/>
- <markdown-field
- v-else
- class="gl-mt-3"
- :markdown-preview-path="markdownPreviewPath"
- :markdown-docs-path="markdownDocsPath"
- :quick-actions-docs-path="quickActionsDocsPath"
- :can-attach-file="canAttachFile"
- :enable-autocomplete="enableAutocomplete"
- :textarea-value="value"
- >
- <template #textarea>
- <textarea
- v-bind="formFieldProps"
- ref="textarea"
- :value="value"
- class="note-textarea js-gfm-input js-autosize markdown-area"
- data-qa-selector="description_field"
- dir="auto"
- data-supports-quick-actions="true"
- @input="$emit('input', $event.target.value)"
- @keydown.meta.enter="updateIssuable"
- @keydown.ctrl.enter="updateIssuable"
- >
- </textarea>
- </template>
- </markdown-field>
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue
index c9e21b296e4..831248d9603 100644
--- a/app/assets/javascripts/issues/show/components/form.vue
+++ b/app/assets/javascripts/issues/show/components/form.vue
@@ -1,6 +1,5 @@
<script>
import { GlAlert } from '@gitlab/ui';
-import ConvertDescriptionModal from 'ee_component/issues/show/components/convert_description_modal.vue';
import { getDraft, updateDraft, getLockVersion, clearDraft } from '~/lib/utils/autosave';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_ISSUE, TYPENAME_USER } from '~/graphql_shared/constants';
@@ -16,7 +15,6 @@ import LockedWarning from './locked_warning.vue';
export default {
components: {
- ConvertDescriptionModal,
DescriptionField,
DescriptionTemplateField,
EditActions,
@@ -175,9 +173,6 @@ export default {
updateDraft(this.descriptionAutosaveKey, description, this.formState.lock_version);
}
},
- setDescription(desc) {
- this.formData.description = desc;
- },
},
};
</script>
@@ -219,14 +214,6 @@ export default {
:project-namespace="projectNamespace"
/>
</div>
-
- <convert-description-modal
- v-if="issueId && glFeatures.generateDescriptionAi"
- class="gl-pl-5 gl-md-pl-0"
- :resource-id="resourceId"
- :user-id="userId"
- @contentGenerated="setDescription"
- />
</div>
<description-field
diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue
index a36b0c46927..719f252781d 100644
--- a/app/assets/javascripts/issues/show/components/header_actions.vue
+++ b/app/assets/javascripts/issues/show/components/header_actions.vue
@@ -13,7 +13,7 @@ import * as Sentry from '@sentry/browser';
import { mapActions, mapGetters, mapState } from 'vuex';
import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
-import { STATUS_CLOSED, TYPE_INCIDENT, TYPE_ISSUE, IssuableTypeText } from '~/issues/constants';
+import { STATUS_CLOSED, TYPE_ISSUE, IssuableTypeText } from '~/issues/constants';
import {
ISSUE_STATE_EVENT_CLOSE,
ISSUE_STATE_EVENT_REOPEN,
@@ -22,7 +22,7 @@ import {
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { getCookie, parseBoolean, setCookie, isLoggedIn } from '~/lib/utils/common_utils';
import { visitUrl } from '~/lib/utils/url_utility';
-import { s__, __, sprintf } from '~/locale';
+import { __, sprintf } from '~/locale';
import eventHub from '~/notes/event_hub';
import Tracking from '~/tracking';
import toast from '~/vue_shared/plugins/global_toast';
@@ -172,12 +172,9 @@ export default {
return this.openState === STATUS_CLOSED;
},
issueTypeText() {
- const issueTypeTexts = {
- [TYPE_ISSUE]: s__('HeaderAction|issue'),
- [TYPE_INCIDENT]: s__('HeaderAction|incident'),
- };
+ const { issueType } = this;
- return issueTypeTexts[this.issueType] ?? this.issueType;
+ return IssuableTypeText[issueType] ?? issueType;
},
buttonText() {
return this.isClosed
@@ -192,11 +189,11 @@ export default {
},
dropdownText() {
return sprintf(__('%{issueType} actions'), {
- issueType: capitalizeFirstCharacter(this.issueType),
+ issueType: capitalizeFirstCharacter(this.issueTypeText),
});
},
newIssueTypeText() {
- return sprintf(__('New related %{issueType}'), { issueType: this.issueType });
+ return sprintf(__('New related %{issueType}'), { issueType: this.issueTypeText });
},
showToggleIssueStateButton() {
const canClose = !this.isClosed && this.canUpdateIssue;
@@ -217,7 +214,7 @@ export default {
},
copyMailAddressText() {
return sprintf(__('Copy %{issueType} email address'), {
- issueType: IssuableTypeText[this.issueType],
+ issueType: this.issueTypeText,
});
},
isMrSidebarMoved() {
@@ -429,7 +426,7 @@ export default {
</gl-button>
<gl-button
- v-if="showToggleIssueStateButton"
+ v-if="showToggleIssueStateButton && !glFeatures.moveCloseIntoDropdown"
class="gl-display-none gl-sm-display-inline-flex!"
:data-qa-selector="qaSelector"
:loading="isToggleStateButtonLoading"
@@ -465,7 +462,12 @@ export default {
<gl-dropdown-divider />
</template>
-
+ <gl-dropdown-item
+ v-if="showToggleIssueStateButton && glFeatures.moveCloseIntoDropdown"
+ @click="toggleIssueState"
+ >
+ {{ buttonText }}
+ </gl-dropdown-item>
<gl-dropdown-item v-if="canCreateIssue && isUserSignedIn" :href="newIssuePath">
{{ newIssueTypeText }}
</gl-dropdown-item>
@@ -495,6 +497,7 @@ export default {
>{{ copyMailAddressText }}</gl-dropdown-item
>
</template>
+ <gl-dropdown-divider v-if="showToggleIssueStateButton || canDestroyIssue || canReportSpam" />
<gl-dropdown-item
v-if="canReportSpam"
:href="submitAsSpamPath"
@@ -503,8 +506,14 @@ export default {
>
{{ __('Submit as spam') }}
</gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="!isIssueAuthor && isUserSignedIn"
+ data-testid="report-abuse-item"
+ @click="toggleReportAbuseDrawer(true)"
+ >
+ {{ $options.i18n.reportAbuse }}
+ </gl-dropdown-item>
<template v-if="canDestroyIssue">
- <gl-dropdown-divider />
<gl-dropdown-item
v-gl-modal="$options.deleteModalId"
variant="danger"
@@ -514,13 +523,6 @@ export default {
{{ deleteButtonText }}
</gl-dropdown-item>
</template>
- <gl-dropdown-item
- v-if="!isIssueAuthor && isUserSignedIn"
- data-testid="report-abuse-item"
- @click="toggleReportAbuseDrawer(true)"
- >
- {{ $options.i18n.reportAbuse }}
- </gl-dropdown-item>
</gl-dropdown>
<new-header-actions-popover v-if="isMrSidebarMoved" :issue-type="issueType" />
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 8267c0130a3..2a59b7a2042 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
@@ -229,7 +229,7 @@ export default {
<template #textarea>
<textarea
v-model="timelineText"
- class="note-textarea js-gfm-input js-autosize markdown-area"
+ class="note-textarea note-textarea-rounded-bottom js-gfm-input js-autosize markdown-area gl-bordered"
data-testid="input-note"
dir="auto"
data-supports-quick-actions="false"
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue
index d33f3146d64..b776822bd9a 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf, GlBadge } from '@gitlab/ui';
+import { GlDisclosureDropdown, GlIcon, GlSprintf, GlBadge } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { formatDate } from '~/lib/utils/datetime_utility';
import { timelineItemI18n } from './constants';
@@ -9,8 +9,7 @@ export default {
name: 'IncidentTimelineEventListItem',
i18n: timelineItemI18n,
components: {
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
GlIcon,
GlSprintf,
GlBadge,
@@ -45,6 +44,25 @@ export default {
canEditEvent() {
return this.action === 'comment';
},
+ items() {
+ const items = [];
+
+ if (this.canEditEvent) {
+ items.push({
+ text: this.$options.i18n.edit,
+ action: () => {
+ this.$emit('edit');
+ },
+ });
+ }
+ items.push({
+ text: this.$options.i18n.delete,
+ action: () => {
+ this.$emit('delete');
+ },
+ });
+ return items;
+ },
},
methods: {
getEventIcon,
@@ -76,22 +94,16 @@ export default {
</div>
<div v-safe-html="noteHtml" class="md"></div>
</div>
- <gl-dropdown
+ <gl-disclosure-dropdown
v-if="canUpdateTimelineEvent"
- right
- class="event-note-actions gl-ml-auto gl-align-self-start"
+ placement="right"
+ class="event-note-actions gl-align-self-start"
icon="ellipsis_v"
text-sr-only
- :text="$options.i18n.moreActions"
+ :toggle-text="$options.i18n.moreActions"
category="tertiary"
no-caret
- >
- <gl-dropdown-item v-if="canEditEvent" @click="$emit('edit')">
- {{ $options.i18n.edit }}
- </gl-dropdown-item>
- <gl-dropdown-item @click="$emit('delete')">
- {{ $options.i18n.delete }}
- </gl-dropdown-item>
- </gl-dropdown>
+ :items="items"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue
index 58b15b3eed1..4cd0d1edbcd 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue
@@ -17,7 +17,7 @@ export default {
<template>
<div>
- <gl-button v-gl-modal="$options.ADD_NAMESPACE_MODAL_ID" category="primary" variant="info">
+ <gl-button v-gl-modal="$options.ADD_NAMESPACE_MODAL_ID" category="primary" variant="confirm">
{{ s__('JiraConnect|Link groups') }}
</gl-button>
<add-namespace-modal />
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
index 7e79572f76d..c5f6f736626 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
@@ -10,6 +10,7 @@ import SignInPage from '../pages/sign_in/sign_in_page.vue';
import SubscriptionsPage from '../pages/subscriptions_page.vue';
import UserLink from './user_link.vue';
import BrowserSupportAlert from './browser_support_alert.vue';
+import FeedbackBanner from './feedback_banner.vue';
export default {
name: 'JiraConnectApp',
@@ -18,6 +19,7 @@ export default {
GlLink,
GlSprintf,
BrowserSupportAlert,
+ FeedbackBanner,
SignInPage,
SubscriptionsPage,
UserLink,
@@ -103,39 +105,47 @@ export default {
<user-link v-if="userSignedIn" :user="currentUser" class="gl-fixed gl-right-4" />
</header>
- <main class="jira-connect-app gl-px-5 gl-pt-7 gl-mx-auto">
- <browser-support-alert v-if="!isBrowserSupported" class="gl-mb-7" />
- <div v-else data-testid="jira-connect-app">
- <gl-alert
- v-if="shouldShowAlert"
- :variant="alert.variant"
- :title="alert.title"
- class="gl-mb-5"
- data-testid="jira-connect-persisted-alert"
- @dismiss="setAlert"
- >
- <gl-sprintf v-if="alert.linkUrl" :message="alert.message">
- <template #link="{ content }">
- <gl-link :href="alert.linkUrl" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
+ <main
+ class="jira-connect-app gl-px-5 gl-pt-7 gl-pb-7 gl-mx-auto gl-display-flex gl-flex-direction-column gl-gap-7"
+ >
+ <div class="gl-flex-grow-1">
+ <browser-support-alert v-if="!isBrowserSupported" class="gl-mb-7" />
+ <div v-else data-testid="jira-connect-app">
+ <gl-alert
+ v-if="shouldShowAlert"
+ :variant="alert.variant"
+ :title="alert.title"
+ class="gl-mb-5"
+ data-testid="jira-connect-persisted-alert"
+ @dismiss="setAlert"
+ >
+ <gl-sprintf v-if="alert.linkUrl" :message="alert.message">
+ <template #link="{ content }">
+ <gl-link :href="alert.linkUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
- <template v-else>
- {{ alert.message }}
- </template>
- </gl-alert>
+ <template v-else>
+ {{ alert.message }}
+ </template>
+ </gl-alert>
- <div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7">
- <sign-in-page
- v-show="!userSignedIn"
- :has-subscriptions="hasSubscriptions"
- :public-key-storage-enabled="publicKeyStorageEnabled"
- @sign-in-oauth="onSignInOauth"
- @error="onSignInError"
- />
- <subscriptions-page v-if="userSignedIn" :has-subscriptions="hasSubscriptions" />
+ <div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7">
+ <sign-in-page
+ v-show="!userSignedIn"
+ :has-subscriptions="hasSubscriptions"
+ :public-key-storage-enabled="publicKeyStorageEnabled"
+ @sign-in-oauth="onSignInOauth"
+ @error="onSignInError"
+ />
+ <subscriptions-page v-if="userSignedIn" :has-subscriptions="hasSubscriptions" />
+ </div>
</div>
</div>
+
+ <div class="gl-flex-grow-2">
+ <feedback-banner class="gl-max-w-80 gl-mx-auto" />
+ </div>
</main>
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/feedback_banner.vue b/app/assets/javascripts/jira_connect/subscriptions/components/feedback_banner.vue
new file mode 100644
index 00000000000..5d6117b836d
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/feedback_banner.vue
@@ -0,0 +1,57 @@
+<script>
+import { GlBanner } from '@gitlab/ui';
+import ChatBubbleSvg from '@gitlab/svgs/dist/illustrations/chat-bubble-sm.svg?url';
+import { s__, __ } from '~/locale';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+
+export default {
+ components: {
+ GlBanner,
+ LocalStorageSync,
+ },
+
+ data() {
+ return {
+ feedbackBannerDismissed: false,
+ };
+ },
+
+ methods: {
+ handleBannerClose() {
+ this.feedbackBannerDismissed = true;
+ },
+ },
+
+ i18n: {
+ title: s__('JiraConnect|Tell us what you think!'),
+ body: s__(
+ 'JiraConnect|We would love to learn more about your experience with the GitLab for Jira Cloud App.',
+ ),
+ dismissLabel: __('Dismiss'),
+ buttonText: __('Give feedback'),
+ },
+ feedbackBannerKey: 'jira_connect_feedback_banner',
+ feedbackIssueUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/413652',
+ buttonAttributes: {
+ target: '_blank',
+ },
+ ChatBubbleSvg,
+};
+</script>
+
+<template>
+ <local-storage-sync v-model="feedbackBannerDismissed" :storage-key="$options.feedbackBannerKey">
+ <gl-banner
+ v-if="!feedbackBannerDismissed"
+ :title="$options.i18n.title"
+ :button-attributes="$options.buttonAttributes"
+ :button-text="$options.i18n.buttonText"
+ :button-link="$options.feedbackIssueUrl"
+ :dismiss-label="$options.i18n.dismissLabel"
+ :svg-path="$options.ChatBubbleSvg"
+ @close="handleBannerClose"
+ >
+ <p>{{ $options.i18n.body }}</p>
+ </gl-banner>
+ </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 45a39fa5fab..ba264d0be34 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
@@ -187,7 +187,7 @@ export default {
<template>
<gl-button
v-bind="$attrs"
- variant="info"
+ variant="confirm"
:loading="loading"
:disabled="!canUseCrypto"
@click="startOAuthFlow"
diff --git a/app/assets/javascripts/jobs/components/job/job_app.vue b/app/assets/javascripts/jobs/components/job/job_app.vue
index d93b8a8de29..a5a92a3c4ff 100644
--- a/app/assets/javascripts/jobs/components/job/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job/job_app.vue
@@ -3,6 +3,7 @@ import { GlLoadingIcon, GlIcon, GlAlert } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { throttle, isEmpty } from 'lodash';
import { mapGetters, mapState, mapActions } from 'vuex';
+import LogTopBar from 'ee_else_ce/jobs/components/job/job_log_controllers.vue';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
import { __, sprintf } from '~/locale';
@@ -13,7 +14,6 @@ import { MANUAL_STATUS } from '~/jobs/constants';
import EmptyState from './empty_state.vue';
import EnvironmentsBlock from './environments_block.vue';
import ErasedBlock from './erased_block.vue';
-import LogTopBar from './job_log_controllers.vue';
import StuckBlock from './stuck_block.vue';
import UnmetPrerequisitesBlock from './unmet_prerequisites_block.vue';
import Sidebar from './sidebar/sidebar.vue';
diff --git a/app/assets/javascripts/jobs/components/job/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job/job_log_controllers.vue
index ea7e13418f2..efd4eed2a9f 100644
--- a/app/assets/javascripts/jobs/components/job/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job/job_log_controllers.vue
@@ -178,6 +178,7 @@ export default {
</script>
<template>
<div class="top-bar gl-display-flex gl-justify-content-space-between">
+ <slot name="drawers"></slot>
<!-- truncate information -->
<div
class="truncated-info gl-display-none gl-sm-display-flex gl-flex-wrap gl-align-items-center"
@@ -197,6 +198,7 @@ export default {
<!-- eo truncate information -->
<div class="controllers">
+ <slot name="controllers"> </slot>
<gl-search-box-by-click
v-model="searchTerm"
class="gl-mr-3"
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue
index 7183a8b5d03..e70f9199b55 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlDropdown, GlDropdownItem, GlModalDirective } from '@gitlab/ui';
+import { GlButton, GlDisclosureDropdown, GlModalDirective } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { JOB_SIDEBAR_COPY } from '~/jobs/constants';
@@ -10,8 +10,7 @@ export default {
},
components: {
GlButton,
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
},
directives: {
GlModal: GlModalDirective,
@@ -32,6 +31,21 @@ export default {
},
computed: {
...mapGetters(['hasForwardDeploymentFailure']),
+ dropdownItems() {
+ return [
+ {
+ text: this.$options.i18n.runAgainJobButtonLabel,
+ href: this.href,
+ extraAttrs: {
+ 'data-method': 'post',
+ },
+ },
+ {
+ text: this.$options.i18n.updateVariables,
+ action: () => this.$emit('updateVariablesClicked'),
+ },
+ ];
+ },
},
};
</script>
@@ -45,20 +59,14 @@ export default {
icon="retry"
data-testid="retry-job-button"
/>
- <gl-dropdown
+ <gl-disclosure-dropdown
v-else-if="isManualJob"
icon="retry"
category="primary"
- :right="true"
+ placement="right"
variant="confirm"
- >
- <gl-dropdown-item :href="href" data-method="post">
- {{ $options.i18n.runAgainJobButtonLabel }}
- </gl-dropdown-item>
- <gl-dropdown-item @click="$emit('updateVariablesClicked')">
- {{ $options.i18n.updateVariables }}
- </gl-dropdown-item>
- </gl-dropdown>
+ :items="dropdownItems"
+ />
<gl-button
v-else
:href="href"
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
index 9a88018205b..c1f84adf664 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
@@ -161,6 +161,11 @@ export default {
</gl-sprintf>
</div>
- <gl-disclosure-dropdown :toggle-text="selectedStage" :items="dropdownItems" class="gl-mt-3" />
+ <gl-disclosure-dropdown
+ :toggle-text="selectedStage"
+ :items="dropdownItems"
+ block
+ class="gl-mt-3"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/job/stuck_block.vue b/app/assets/javascripts/jobs/components/job/stuck_block.vue
index d7a26d22406..1a678ce69a8 100644
--- a/app/assets/javascripts/jobs/components/job/stuck_block.vue
+++ b/app/assets/javascripts/jobs/components/job/stuck_block.vue
@@ -1,6 +1,7 @@
<script>
import { GlAlert, GlBadge, GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
+import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility';
/**
* Renders Stuck Runners block for job's view.
*/
@@ -31,7 +32,7 @@ export default {
return this.tags.length > 0;
},
protectedBranchSettingsDocsLink() {
- return 'https://docs.gitlab.com/runner/security/index.html#reduce-the-security-risk-of-using-privileged-containers';
+ return `${DOCS_URL}/runner/security/index.html#reduce-the-security-risk-of-using-privileged-containers`;
},
stuckData() {
if (this.hasNoRunnersWithCorrespondingTags) {
diff --git a/app/assets/javascripts/jobs/components/table/cells/job_cell.vue b/app/assets/javascripts/jobs/components/table/cells/job_cell.vue
index b692553fdc2..27d286fc766 100644
--- a/app/assets/javascripts/jobs/components/table/cells/job_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/job_cell.vue
@@ -71,7 +71,7 @@ export default {
<template>
<div>
- <div class="gl-text-truncate">
+ <div class="gl-text-truncate gl-mb-2">
<gl-link
v-if="canReadJob"
class="gl-text-blue-600!"
@@ -92,7 +92,7 @@ export default {
/>
<div
- class="gl-display-flex gl-text-gray-700 gl-align-items-center gl-lg-justify-content-start gl-justify-content-end"
+ class="gl-display-flex gl-text-gray-700 gl-align-items-center gl-lg-justify-content-start gl-justify-content-end gl-mt-2"
>
<div
v-if="jobRef"
diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js
index 44bb1ffb1bc..8cd69f25218 100644
--- a/app/assets/javascripts/jobs/index.js
+++ b/app/assets/javascripts/jobs/index.js
@@ -2,6 +2,7 @@ import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
import JobApp from './components/job/job_app.vue';
import createStore from './store';
@@ -29,6 +30,7 @@ const initializeJobPage = (element) => {
buildStatus,
projectPath,
retryOutdatedJobDocsUrl,
+ aiRootCauseAnalysisAvailable,
} = element.dataset;
return new Vue({
@@ -41,6 +43,7 @@ const initializeJobPage = (element) => {
provide: {
projectPath,
retryOutdatedJobDocsUrl,
+ aiRootCauseAnalysisAvailable: parseBoolean(aiRootCauseAnalysisAvailable),
},
render(createElement) {
return createElement('job-app', {
diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js
index 42682d9b79f..670170ec9b9 100644
--- a/app/assets/javascripts/layout_nav.js
+++ b/app/assets/javascripts/layout_nav.js
@@ -66,15 +66,15 @@ export function initScrollingTabs() {
function initInviteMembers() {
const modalEl = document.querySelector('.js-invite-members-modal');
- if (!modalEl) return;
-
- import(
- /* webpackChunkName: 'initInviteMembersModal' */ '~/invite_members/init_invite_members_modal'
- )
- .then(({ default: initInviteMembersModal }) => {
- initInviteMembersModal();
- })
- .catch(() => {});
+ if (modalEl) {
+ import(
+ /* webpackChunkName: 'initInviteMembersModal' */ '~/invite_members/init_invite_members_modal'
+ )
+ .then(({ default: initInviteMembersModal }) => {
+ initInviteMembersModal();
+ })
+ .catch(() => {});
+ }
const inviteTriggers = document.querySelectorAll('.js-invite-members-trigger');
if (!inviteTriggers) return;
diff --git a/app/assets/javascripts/lib/logger/hello.js b/app/assets/javascripts/lib/logger/hello.js
index ccfdfe91e60..4ad99ec09d8 100644
--- a/app/assets/javascripts/lib/logger/hello.js
+++ b/app/assets/javascripts/lib/logger/hello.js
@@ -1,4 +1,5 @@
import { s__, sprintf } from '~/locale';
+import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
const HANDSHAKE = String.fromCodePoint(0x1f91d);
const MAG = String.fromCodePoint(0x1f50e);
@@ -15,7 +16,7 @@ ${s__(
${sprintf(s__('HelloMessage|%{handshake_emoji} Contribute to GitLab: %{contribute_link}'), {
handshake_emoji: `${HANDSHAKE}`,
- contribute_link: 'https://about.gitlab.com/community/contribute/',
+ contribute_link: `${PROMO_URL}/community/contribute/`,
})}
${sprintf(s__('HelloMessage|%{magnifier_emoji} Create a new GitLab issue: %{new_issue_link}'), {
magnifier_emoji: `${MAG}`,
@@ -27,7 +28,7 @@ ${
s__(
'HelloMessage|%{rocket_emoji} We like your curiosity! Help us improve GitLab by joining the team: %{jobs_page_link}',
),
- { rocket_emoji: `${ROCKET}`, jobs_page_link: 'https://about.gitlab.com/jobs/' },
+ { rocket_emoji: `${ROCKET}`, jobs_page_link: `${PROMO_URL}/jobs/` },
)}`
: ''
}`,
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 7795dac18bc..cca4cf68f5e 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -721,3 +721,13 @@ export const getFirstPropertyValue = (data) => {
return data[key];
};
+
+export const isCurrentUser = (userId) => {
+ const currentUserId = window.gon?.current_user_id;
+
+ if (!currentUserId) {
+ return false;
+ }
+
+ return Number(userId) === currentUserId;
+};
diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
index 24be1485379..b61f01590cd 100644
--- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
+++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
@@ -68,7 +68,7 @@ export default {
text: this.primaryText,
attributes: {
variant: this.primaryVariant,
- 'data-qa-selector': 'confirm_ok_button',
+ 'data-testid': 'confirm-ok-button',
},
};
},
@@ -110,6 +110,7 @@ export default {
ref="modal"
modal-id="confirmationModal"
body-class="gl-display-flex"
+ data-testid="confirmation-modal"
:size="size"
:title="title"
:action-primary="primaryAction"
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 e1a57bf4589..b0264796d90 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
@@ -3,6 +3,7 @@ import dateFormat from '~/lib/dateformat';
import { roundToNearestHalf } from '~/lib/utils/common_utils';
import { sanitize } from '~/lib/dompurify';
import { s__, n__, __, sprintf } from '~/locale';
+import { parsePikadayDate } from './pikaday_utility';
/**
* Returns i18n month names array.
@@ -420,3 +421,34 @@ export const formatUtcOffset = (offset) => {
* @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}`;
+
+/**
+ * Returns humanized string showing date range from provided start and due dates.
+ *
+ * @param {Date} startDate
+ * @param {Date} dueDate
+ * @returns
+ */
+export const humanTimeframe = (startDate, dueDate) => {
+ const start = startDate ? parsePikadayDate(startDate) : null;
+ const due = dueDate ? parsePikadayDate(dueDate) : null;
+
+ if (startDate && dueDate) {
+ const startDateInWords = dateInWords(start, true, start.getFullYear() === due.getFullYear());
+ const dueDateInWords = dateInWords(due, true);
+
+ return sprintf(__('%{startDate} – %{dueDate}'), {
+ startDate: startDateInWords,
+ dueDate: dueDateInWords,
+ });
+ } else if (startDate && !dueDate) {
+ return sprintf(__('%{startDate} – No due date'), {
+ startDate: dateInWords(start, true, false),
+ });
+ } else if (!startDate && dueDate) {
+ return sprintf(__('No start date – %{dueDate}'), {
+ dueDate: dateInWords(due, true, false),
+ });
+ }
+ return '';
+};
diff --git a/app/assets/javascripts/lib/utils/forms.js b/app/assets/javascripts/lib/utils/forms.js
index 1d8c6ee23fc..652ae337506 100644
--- a/app/assets/javascripts/lib/utils/forms.js
+++ b/app/assets/javascripts/lib/utils/forms.js
@@ -18,18 +18,69 @@ export const serializeForm = (form) => {
};
/**
+ * Like trim but without the error for non-string values.
+ *
+ * @param {String, Number, Array} - value
+ * @returns {String, Number, Array} - the trimmed string or the value if it isn't a string
+ */
+export const safeTrim = (value) => (typeof value === 'string' ? value.trim() : value);
+
+/**
* Check if the value provided is empty or not
*
* It is being used to check if a form input
- * value has been set or not
+ * value has been set or not.
*
* @param {String, Number, Array} - Any form value
* @returns {Boolean} - returns false if a value is set
*
* @example
- * returns true for '', [], null, undefined
+ * returns true for '', ' ', [], null, undefined
+ */
+export const isEmptyValue = (value) => value == null || safeTrim(value).length === 0;
+
+/**
+ * Check if the value has a minimum string length
+ *
+ * @param {String, Number, Array} - Any form value
+ * @param {Number} - minLength
+ * @returns {Boolean}
+ */
+export const hasMinimumLength = (value, minLength) =>
+ !isEmptyValue(value) && value.length >= minLength;
+
+/**
+ * Checks if the given value can be parsed as an integer as it is (without cutting off decimals etc.)
+ *
+ * @param {String, Number, Array} - Any form value
+ * @returns {Boolean}
+ */
+export const isParseableAsInteger = (value) =>
+ !isEmptyValue(value) && Number.isInteger(Number(safeTrim(value)));
+
+/**
+ * Checks if the parsed integer value from the given input is greater than a certain number
+ *
+ * @param {String, Number, Array} - Any form value
+ * @param {Number} - greaterThan
+ * @returns {Boolean}
+ */
+export const isIntegerGreaterThan = (value, greaterThan) =>
+ isParseableAsInteger(value) && parseInt(value, 10) > greaterThan;
+
+/**
+ * Regexp that matches email structure.
+ * Taken from app/models/service_desk_setting.rb custom_email
+ */
+export const EMAIL_REGEXP = /^[\w\-._]+@[\w\-.]+\.[a-zA-Z]{2,}$/;
+
+/**
+ * Checks if the input is a valid email address
+ *
+ * @param {String} - value
+ * @returns {Boolean}
*/
-export const isEmptyValue = (value) => value == null || value.length === 0;
+export const isEmail = (value) => EMAIL_REGEXP.test(value);
/**
* A form object serializer
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index a2873622682..e6eb74834c0 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -376,8 +376,8 @@ export function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select
textArea = $textArea.get(0);
const text = $textArea.val();
const selected = selectedText(text, textArea) || tagContent;
- $textArea.focus();
- return insertMarkdownText({
+ textArea.focus();
+ insertMarkdownText({
textArea,
text,
tag,
@@ -387,6 +387,7 @@ export function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select
wrap,
select,
});
+ textArea.click();
}
/**
@@ -596,6 +597,7 @@ export function compositionEndNoteText() {
export function updateTextForToolbarBtn($toolbarBtn) {
const $textArea = $toolbarBtn.closest('.md-area').find('textarea');
+ if (!$textArea.length) return;
switch ($toolbarBtn.data('mdCommand')) {
case 'indentLines':
diff --git a/app/assets/javascripts/lib/utils/vue3compat/vue_router.js b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js
index aa2963ece31..62054d5a80d 100644
--- a/app/assets/javascripts/lib/utils/vue3compat/vue_router.js
+++ b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js
@@ -26,24 +26,26 @@ const mode = (value, options) => {
const base = () => null;
-const toNewCatchAllPath = (path) => {
- if (path === '*') return '/:pathMatch(.*)*';
+const toNewCatchAllPath = (path, { isRoot } = {}) => {
+ if (path === '*') {
+ const prefix = isRoot ? '/' : '';
+ return `${prefix}:pathMatch(.*)*`;
+ }
return path;
};
-const routes = (value) => {
+const transformRoutes = (value, _routerOptions, transformOptions = { isRoot: true }) => {
if (!value) return null;
- const newRoutes = value.reduce(function handleRoutes(acc, route) {
+ const newRoutes = value.map(function handleRoutes(route) {
const newRoute = {
...route,
- path: toNewCatchAllPath(route.path),
+ path: toNewCatchAllPath(route.path, transformOptions),
};
if (route.children) {
- newRoute.children = route.children.reduce(handleRoutes, []);
+ newRoute.children = transformRoutes(route.children, _routerOptions, { isRoot: false }).routes;
}
- acc.push(newRoute);
- return acc;
- }, []);
+ return newRoute;
+ });
return { routes: newRoutes };
};
@@ -59,7 +61,7 @@ const scrollBehavior = (value) => {
const transformers = {
mode,
base,
- routes,
+ routes: transformRoutes,
scrollBehavior,
};
@@ -107,7 +109,15 @@ export default class VueRouterCompat {
installed.set(app, new WeakSet());
}
installed.get(app).add(router);
+
+ // Since we're doing "late initialization" we might already have RouterLink
+ // for example, from router stubs. We need to maintain it
+ const originalRouterLink = this.$.appContext.components.RouterLink;
+ delete this.$.appContext.components.RouterLink;
this.$.appContext.app.use(this.$options.router);
+ if (originalRouterLink) {
+ this.$.appContext.components.RouterLink = originalRouterLink;
+ }
}
},
});
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index c973d58fcd2..f6fd84c46cb 100644
--- a/app/assets/javascripts/members/components/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -127,10 +127,15 @@ export default {
},
actionsFieldTdClass(value, key, member) {
if (this.hasActionButtons(member)) {
- return 'col-actions';
+ return ['col-actions', 'gl-vertical-align-middle!'];
}
- return ['col-actions', 'gl-display-none!', 'gl-lg-display-table-cell!'];
+ return [
+ 'col-actions',
+ 'gl-display-none!',
+ 'gl-lg-display-table-cell!',
+ 'gl-vertical-align-middle!',
+ ];
},
tbodyTrAttr(member) {
return {
diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue
index 4571c4172e5..c854d865869 100644
--- a/app/assets/javascripts/members/components/table/role_dropdown.vue
+++ b/app/assets/javascripts/members/components/table/role_dropdown.vue
@@ -76,6 +76,7 @@ export default {
newRoleName,
);
if (!confirmed) {
+ this.selectedRoleValue = currentRoleValue;
this.busy = false;
return;
}
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index 8e5b88d362e..e1f7e81d831 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -32,12 +32,13 @@ export const FIELDS = [
asc: 'name_asc',
desc: 'name_desc',
},
+ tdClass: 'gl-vertical-align-middle!',
},
{
key: FIELD_KEY_SOURCE,
label: __('Source'),
thClass: 'col-meta',
- tdClass: 'col-meta',
+ tdClass: 'col-meta gl-vertical-align-middle!',
},
{
key: FIELD_KEY_GRANTED,
@@ -46,24 +47,25 @@ export const FIELDS = [
asc: 'last_joined',
desc: 'oldest_joined',
},
+ tdClass: 'gl-vertical-align-middle!',
},
{
key: FIELD_KEY_INVITED,
label: __('Invited'),
thClass: 'col-meta',
- tdClass: 'col-meta',
+ tdClass: 'col-meta gl-vertical-align-middle!',
},
{
key: FIELD_KEY_REQUESTED,
label: __('Requested'),
thClass: 'col-meta',
- tdClass: 'col-meta',
+ tdClass: 'col-meta gl-vertical-align-middle!',
},
{
key: FIELD_KEY_MAX_ROLE,
label: __('Max role'),
thClass: 'col-max-role',
- tdClass: 'col-max-role',
+ tdClass: 'col-max-role gl-vertical-align-middle!',
sort: {
asc: 'access_level_asc',
desc: 'access_level_desc',
@@ -73,13 +75,13 @@ export const FIELDS = [
key: FIELD_KEY_EXPIRATION,
label: __('Expiration'),
thClass: 'col-expiration',
- tdClass: 'col-expiration',
+ tdClass: 'col-expiration gl-vertical-align-middle!',
},
{
key: FIELD_KEY_ACTIVITY,
label: s__('Members|Activity'),
thClass: 'col-activity',
- tdClass: 'col-activity',
+ tdClass: 'col-activity gl-vertical-align-middle!',
},
{
key: FIELD_KEY_USER_CREATED_AT,
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 8307d0a9eed..883b9e6919b 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -21,7 +21,12 @@ import syntaxHighlight from './syntax_highlight';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ useGet: true,
+ },
+ ),
});
// MergeRequestTabs
@@ -96,6 +101,7 @@ function mountPipelines() {
artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder,
targetProjectFullPath: mrWidgetData?.target_project_full_path || '',
fullPath: pipelineTableViewEl.dataset.fullPath,
+ graphqlPath: pipelineTableViewEl.dataset.graphqlPath,
manualActionsLimit: 50,
withFailedJobsDetails: true,
},
diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue
index c6e8a9ea582..362ecca6d6c 100644
--- a/app/assets/javascripts/merge_requests/components/sticky_header.vue
+++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue
@@ -113,14 +113,14 @@ export default {
class="issue-sticky-header-text gl-display-flex gl-flex-direction-column gl-align-items-center gl-mx-auto gl-px-5 gl-w-full"
:class="{ 'gl-max-w-container-xl': !isFluidLayout }"
>
- <div class="gl-w-full gl-display-flex gl-align-items-center">
+ <div class="gl-w-full gl-display-flex gl-align-items-baseline">
<status-box :initial-state="getNoteableData.state" issuable-type="merge_request" />
<a
v-safe-html:[$options.safeHtmlConfig]="titleHtml"
href="#top"
class="gl-display-none gl-lg-display-block gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-mr-4 gl-text-black-normal"
></a>
- <div class="gl-display-flex gl-align-items-center">
+ <div class="gl-display-flex gl-align-items-baseline">
<gl-sprintf :message="__('%{source} %{copyButton} into %{target}')">
<template #copyButton>
<clipboard-button
@@ -129,7 +129,7 @@ export default {
size="small"
category="tertiary"
tooltip-placement="bottom"
- class="gl-m-0! gl-mx-1! js-source-branch-copy"
+ class="gl-m-0! gl-mx-1! js-source-branch-copy gl-align-self-center"
/>
</template>
<template #source>
diff --git a/app/assets/javascripts/merge_requests/generated_content.js b/app/assets/javascripts/merge_requests/generated_content.js
new file mode 100644
index 00000000000..0184801ce80
--- /dev/null
+++ b/app/assets/javascripts/merge_requests/generated_content.js
@@ -0,0 +1,64 @@
+export class MergeRequestGeneratedContent {
+ constructor({ editor } = {}) {
+ this.warningElement = document.querySelector('.js-ai-description-warning');
+ this.markdownEditor = editor;
+ this.generatedContent = null;
+
+ this.connectToDOM();
+ }
+
+ get hasEditor() {
+ return Boolean(this.markdownEditor);
+ }
+ get hasWarning() {
+ return Boolean(this.warningElement);
+ }
+ get canReplaceContent() {
+ return this.hasEditor && Boolean(this.generatedContent);
+ }
+
+ connectToDOM() {
+ let close;
+ let cancel;
+ let approve;
+
+ if (this.hasWarning) {
+ approve = this.warningElement.querySelector('.js-ai-override-description');
+ cancel = this.warningElement.querySelector('.js-cancel-btn');
+ close = this.warningElement.querySelector('.js-close-btn');
+
+ approve.addEventListener('click', () => {
+ this.replaceDescription();
+ this.hideWarning();
+ });
+
+ cancel.addEventListener('click', () => this.hideWarning());
+ close.addEventListener('click', () => this.hideWarning());
+ }
+ }
+
+ setEditor(markdownEditor) {
+ this.markdownEditor = markdownEditor;
+ }
+ setGeneratedContent(newContent) {
+ this.generatedContent = newContent;
+ }
+ clearGeneratedContent() {
+ this.generatedContent = null;
+ }
+
+ showWarning() {
+ if (this.canReplaceContent) {
+ this.warningElement?.classList.remove('hidden');
+ }
+ }
+ hideWarning() {
+ this.warningElement?.classList.add('hidden');
+ }
+ replaceDescription() {
+ if (this.canReplaceContent) {
+ this.markdownEditor.setValue(this.generatedContent);
+ this.clearGeneratedContent();
+ }
+ }
+}
diff --git a/app/assets/javascripts/milestones/index.js b/app/assets/javascripts/milestones/index.js
index 420f7cee4d2..403db0865f0 100644
--- a/app/assets/javascripts/milestones/index.js
+++ b/app/assets/javascripts/milestones/index.js
@@ -1,10 +1,9 @@
-import $ from 'jquery';
import Vue from 'vue';
import initDatePicker from '~/behaviors/date_picker';
-import GLForm from '~/gl_form';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import Milestone from '~/milestones/milestone';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
+import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
import Sidebar from '~/right_sidebar';
import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar';
import Translate from '~/vue_shared/translate';
@@ -22,22 +21,10 @@ export const MILESTONE_DESCRIPTION_ELEMENT = '.milestone-detail .description';
export const MILESTONE_DESCRIPTION_TASK_LIST_CONTAINER_ELEMENT = `${MILESTONE_DESCRIPTION_ELEMENT}.js-task-list-container`;
export const MILESTONE_DETAIL_ELEMENT = '.milestone-detail';
-export function initForm(initGFM = true) {
+export function initForm() {
+ mountMarkdownEditor();
new ZenMode(); // eslint-disable-line no-new
initDatePicker();
-
- // eslint-disable-next-line no-new
- new GLForm($('.milestone-form'), {
- emojis: true,
- members: initGFM,
- issues: initGFM,
- mergeRequests: initGFM,
- epics: initGFM,
- milestones: initGFM,
- labels: initGFM,
- snippets: initGFM,
- vulnerabilities: initGFM,
- });
}
export function initShow() {
diff --git a/app/assets/javascripts/ml/model_registry/routes/models/index/components/ml_models_index.vue b/app/assets/javascripts/ml/model_registry/routes/models/index/components/ml_models_index.vue
new file mode 100644
index 00000000000..37e5877ec52
--- /dev/null
+++ b/app/assets/javascripts/ml/model_registry/routes/models/index/components/ml_models_index.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+import * as translations from '~/ml/model_registry/routes/models/index/translations';
+
+export default {
+ name: 'MlExperimentsIndexApp',
+ components: {
+ GlLink,
+ },
+ props: {
+ models: {
+ type: Array,
+ required: true,
+ },
+ },
+ i18n: translations,
+};
+</script>
+
+<template>
+ <div>
+ <div class="detail-page-header gl-flex-wrap">
+ <div class="detail-page-header-body">
+ <div class="page-title gl-flex-grow-1 gl-display-flex gl-align-items-center">
+ <h2 class="gl-font-size-h-display gl-my-0">{{ $options.i18n.TITLE_LABEL }}</h2>
+ </div>
+ </div>
+ </div>
+
+ <div v-for="model in models" :key="model.name">
+ <gl-link :href="model.path"> {{ model.name }} / {{ model.version }} </gl-link>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ml/model_registry/routes/models/index/index.js b/app/assets/javascripts/ml/model_registry/routes/models/index/index.js
new file mode 100644
index 00000000000..d303d9716af
--- /dev/null
+++ b/app/assets/javascripts/ml/model_registry/routes/models/index/index.js
@@ -0,0 +1,3 @@
+import MlModelsIndex from './components/ml_models_index.vue';
+
+export default MlModelsIndex;
diff --git a/app/assets/javascripts/ml/model_registry/routes/models/index/translations.js b/app/assets/javascripts/ml/model_registry/routes/models/index/translations.js
new file mode 100644
index 00000000000..f0f45f9424e
--- /dev/null
+++ b/app/assets/javascripts/ml/model_registry/routes/models/index/translations.js
@@ -0,0 +1,3 @@
+import { s__ } from '~/locale';
+
+export const TITLE_LABEL = s__('MlExperimentTracking|Model registry');
diff --git a/app/assets/javascripts/monitoring/components/charts/annotations.js b/app/assets/javascripts/monitoring/components/charts/annotations.js
deleted file mode 100644
index aac9d2f8a01..00000000000
--- a/app/assets/javascripts/monitoring/components/charts/annotations.js
+++ /dev/null
@@ -1,133 +0,0 @@
-import { graphTypes, symbolSizes, colorValues, annotationsSymbolIcon } from '../../constants';
-
-/**
- * Annotations and deployments are decoration layers on
- * top of the actual chart data. We use a scatter plot to
- * display this information. Each chart has its coordinate
- * system based on data and irrespective of the data, these
- * decorations have to be placed in specific locations.
- * For this reason, annotations have their own coordinate system,
- *
- * As of %12.9, only deployment icons, a type of annotations, need
- * to be displayed on the chart.
- *
- * Annotations and deployments co-exist in the same series as
- * they logically belong together. Annotations are passed as
- * markLines and markPoints while deployments are passed as
- * data points with custom icons.
- */
-
-/**
- * Deployment icons, a type of annotation, are displayed
- * along the [min, max] range at height `pos`.
- */
-const annotationsYAxisCoords = {
- min: 0,
- pos: 3, // 3% height of chart's grid
- max: 100,
-};
-
-/**
- * Annotation y axis min & max allows the deployment
- * icons to position correctly in the chart
- */
-export const annotationsYAxis = {
- show: false,
- min: annotationsYAxisCoords.min,
- max: annotationsYAxisCoords.max,
- axisLabel: {
- // formatter fn required to trigger tooltip re-positioning
- formatter: () => {},
- },
-};
-
-/**
- * Fetched list of annotations are parsed into a
- * format the eCharts accepts to draw markLines
- *
- * If Annotation is a single line, the `startingAt` property
- * has a value and the `endingAt` is null. Because annotations
- * only supports lines the `endingAt` value does not exist yet.
- *
- * @param {Object} annotation object
- * @returns {Object} markLine object
- */
-export const parseAnnotations = (annotations) =>
- annotations.reduce(
- (acc, annotation) => {
- acc.lines.push({
- xAxis: annotation.startingAt,
- lineStyle: {
- color: colorValues.primaryColor,
- },
- });
-
- acc.points.push({
- name: 'annotations',
- xAxis: annotation.startingAt,
- yAxis: annotationsYAxisCoords.min,
- tooltipData: {
- title: annotation.startingAt,
- content: annotation.description,
- },
- });
-
- return acc;
- },
- { lines: [], points: [] },
- );
-
-/**
- * This method generates a decorative series that has
- * deployments as data points with custom icons and
- * annotations as markLines and markPoints
- *
- * @param {Array} deployments deployments data
- * @returns {Object} annotation series object
- */
-export const generateAnnotationsSeries = ({ deployments = [], annotations = [] } = {}) => {
- // deployment data points
- const data = deployments.map((deployment) => {
- return {
- name: 'deployments',
- value: [deployment.createdAt, annotationsYAxisCoords.pos],
- // style options
- symbol: deployment.icon,
- symbolSize: symbolSizes.default,
- itemStyle: {
- color: deployment.color,
- },
- // metadata that are accessible in `formatTooltipText` method
- tooltipData: {
- sha: deployment.sha.substring(0, 8),
- commitUrl: deployment.commitUrl,
- },
- };
- });
-
- const parsedAnnotations = parseAnnotations(annotations);
-
- // markLine option draws the annotations dotted line
- const markLine = {
- symbol: 'none',
- silent: true,
- data: parsedAnnotations.lines,
- };
-
- // markPoints are the arrows under the annotations lines
- const markPoint = {
- symbol: annotationsSymbolIcon,
- symbolSize: '8',
- symbolOffset: [0, ' 60%'],
- data: parsedAnnotations.points,
- };
-
- return {
- name: 'annotations',
- type: graphTypes.annotationsData,
- yAxisIndex: 1, // annotationsYAxis index
- data,
- markLine,
- markPoint,
- };
-};
diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
deleted file mode 100644
index b6eb1a23f87..00000000000
--- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue
+++ /dev/null
@@ -1,230 +0,0 @@
-<script>
-import { GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
-import { hexToRgba } from '@gitlab/ui/dist/utils/utils';
-
-import produce from 'immer';
-import { flattenDeep, isNumber } from 'lodash';
-import { roundOffFloat } from '~/lib/utils/common_utils';
-import { areaOpacityValues, symbolSizes, colorValues, panelTypes } from '../../constants';
-import { graphDataValidatorForAnomalyValues } from '../../utils';
-import MonitorTimeSeriesChart from './time_series.vue';
-
-/**
- * Series indexes
- */
-const METRIC = 0;
-const UPPER = 1;
-const LOWER = 2;
-
-/**
- * Boundary area appearance
- */
-const AREA_COLOR = colorValues.anomalyAreaColor;
-const AREA_OPACITY = areaOpacityValues.default;
-const AREA_COLOR_RGBA = hexToRgba(AREA_COLOR, AREA_OPACITY);
-
-/**
- * The anomaly component highlights when a metric shows
- * some anomalous behavior.
- *
- * It shows both a metric line and a boundary band in a
- * time series chart, the boundary band shows the normal
- * range of values the metric should take.
- *
- * This component accepts 3 metrics, which contain the
- * "metric", "upper" limit and "lower" limit.
- *
- * The upper and lower series are "stacked areas" visually
- * to create the boundary band, and if any "metric" value
- * is outside this band, it is highlighted to warn users.
- *
- * The boundary band stack must be painted above the 0 line
- * so the area is shown correctly. If any of the values of
- * the data are negative, the chart data is shifted to be
- * above 0 line.
- *
- * The data passed to the time series is will always be
- * positive, but reformatted to show the original values of
- * data.
- *
- */
-export default {
- components: {
- GlChartSeriesLabel,
- MonitorTimeSeriesChart,
- },
- inheritAttrs: false,
- props: {
- graphData: {
- type: Object,
- required: true,
- validator: graphDataValidatorForAnomalyValues,
- },
- },
- computed: {
- series() {
- return this.graphData.metrics.map((metric) => {
- const values = metric.result && metric.result[0] ? metric.result[0].values : [];
- return {
- label: metric.label,
- // NaN values may disrupt avg., max. & min. calculations in the legend, filter them out
- data: values.filter(([, value]) => !Number.isNaN(value)),
- };
- });
- },
- /**
- * If any of the values of the data is negative, the
- * chart data is shifted to the lowest value
- *
- * This offset is the lowest value.
- */
- yOffset() {
- const values = flattenDeep(this.series.map((ser) => ser.data.map(([, y]) => y)));
- const min = values.length ? Math.floor(Math.min(...values)) : 0;
- return min < 0 ? -min : 0;
- },
- metricData() {
- const originalMetricQuery = this.graphData.metrics[0];
-
- const metricQuery = produce(originalMetricQuery, (draftQuery) => {
- draftQuery.result[0].values = draftQuery.result[0].values.map(([x, y]) => [
- x,
- y + this.yOffset,
- ]);
- });
- return {
- ...this.graphData,
- type: panelTypes.LINE_CHART,
- metrics: [metricQuery],
- };
- },
- metricSeriesConfig() {
- return {
- type: 'line',
- symbol: 'circle',
- symbolSize: (val, params) => {
- if (this.isDatapointAnomaly(params.dataIndex)) {
- return symbolSizes.anomaly;
- }
- // 0 causes echarts to throw an error, use small number instead
- // see https://gitlab.com/gitlab-org/gitlab-ui/issues/423
- return 0.001;
- },
- showSymbol: true,
- itemStyle: {
- color: (params) => {
- if (this.isDatapointAnomaly(params.dataIndex)) {
- return colorValues.anomalySymbol;
- }
- return colorValues.primaryColor;
- },
- },
- };
- },
- chartOptions() {
- const [, upperSeries, lowerSeries] = this.series;
- const calcOffsetY = (data, offsetCallback) =>
- data.map((value, dataIndex) => {
- const [x, y] = value;
- return [x, y + offsetCallback(dataIndex)];
- });
-
- const yAxisWithOffset = {
- axisLabel: {
- formatter: (num) => roundOffFloat(num - this.yOffset, 3).toString(),
- },
- };
-
- /**
- * Boundary is rendered by 2 series: An invisible
- * series (opacity: 0) stacked on a visible one.
- *
- * Order is important, lower boundary is stacked
- * *below* the upper boundary.
- */
- const boundarySeries = [];
-
- if (upperSeries.data.length && lowerSeries.data.length) {
- // Lower boundary, plus the offset if negative values
- boundarySeries.push(
- this.makeBoundarySeries({
- name: this.formatLegendLabel(lowerSeries),
- data: calcOffsetY(lowerSeries.data, () => this.yOffset),
- }),
- );
- // Upper boundary, minus the lower boundary
- boundarySeries.push(
- this.makeBoundarySeries({
- name: this.formatLegendLabel(upperSeries),
- data: calcOffsetY(upperSeries.data, (i) => -this.yValue(LOWER, i)),
- areaStyle: {
- color: AREA_COLOR,
- opacity: AREA_OPACITY,
- },
- }),
- );
- }
-
- return { yAxis: yAxisWithOffset, series: boundarySeries };
- },
- },
- methods: {
- formatLegendLabel(query) {
- return query.label;
- },
- yValue(seriesIndex, dataIndex) {
- const d = this.series[seriesIndex].data[dataIndex];
- return d && d[1];
- },
- yValueFormatted(seriesIndex, dataIndex) {
- const y = this.yValue(seriesIndex, dataIndex);
- return isNumber(y) ? y.toFixed(3) : '';
- },
- isDatapointAnomaly(dataIndex) {
- const yVal = this.yValue(METRIC, dataIndex);
- const yUpper = this.yValue(UPPER, dataIndex);
- const yLower = this.yValue(LOWER, dataIndex);
- return (isNumber(yUpper) && yVal > yUpper) || (isNumber(yLower) && yVal < yLower);
- },
- makeBoundarySeries(series) {
- const stackKey = 'anomaly-boundary-series-stack';
- return {
- type: 'line',
- stack: stackKey,
- lineStyle: {
- width: 0,
- color: AREA_COLOR_RGBA, // legend color
- },
- color: AREA_COLOR_RGBA, // tooltip color
- symbol: 'none',
- ...series,
- };
- },
- },
-};
-</script>
-
-<template>
- <monitor-time-series-chart
- v-bind="$attrs"
- :graph-data="metricData"
- :option="chartOptions"
- :series-config="metricSeriesConfig"
- >
- <slot></slot>
- <template #tooltip-content="slotProps">
- <div
- v-for="(content, seriesIndex) in slotProps.tooltip.content"
- :key="seriesIndex"
- class="d-flex justify-content-between"
- >
- <gl-chart-series-label :color="content.color">
- {{ content.name }}
- </gl-chart-series-label>
- <div class="gl-ml-7">
- {{ yValueFormatted(seriesIndex, content.dataIndex) }}
- </div>
- </div>
- </template>
- </monitor-time-series-chart>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/bar.vue b/app/assets/javascripts/monitoring/components/charts/bar.vue
deleted file mode 100644
index df91bd078d1..00000000000
--- a/app/assets/javascripts/monitoring/components/charts/bar.vue
+++ /dev/null
@@ -1,87 +0,0 @@
-<script>
-import { GlBarChart } from '@gitlab/ui/dist/charts';
-import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
-import { chartHeight } from '../../constants';
-import { barChartsDataParser, graphDataValidatorForValues } from '../../utils';
-
-export default {
- components: {
- GlBarChart,
- },
- props: {
- graphData: {
- type: Object,
- required: true,
- validator: graphDataValidatorForValues.bind(null, false),
- },
- },
- data() {
- return {
- width: 0,
- height: chartHeight,
- svgs: {},
- };
- },
- computed: {
- chartData() {
- return barChartsDataParser(this.graphData.metrics);
- },
- chartOptions() {
- return {
- dataZoom: [this.dataZoomConfig],
- };
- },
- xAxisTitle() {
- const { xLabel = '' } = this.graphData;
- return xLabel;
- },
- yAxisTitle() {
- const { y_label: yLabel = '' } = this.graphData;
- return yLabel;
- },
- xAxisType() {
- const { x_type: xType = 'value' } = this.graphData;
- return xType;
- },
- dataZoomConfig() {
- const handleIcon = this.svgs['scroll-handle'];
-
- return handleIcon ? { handleIcon } : {};
- },
- },
- created() {
- this.setSvg('scroll-handle');
- },
- methods: {
- formatLegendLabel(query) {
- return query.label;
- },
- setSvg(name) {
- getSvgIconPathContent(name)
- .then((path) => {
- if (path) {
- this.$set(this.svgs, name, `path://${path}`);
- }
- })
- .catch((e) => {
- // eslint-disable-next-line no-console, @gitlab/require-i18n-strings
- console.error('SVG could not be rendered correctly: ', e);
- });
- },
- },
-};
-</script>
-<template>
- <gl-bar-chart
- ref="barChart"
- v-bind="$attrs"
- :responsive="true"
- :data="chartData"
- :option="chartOptions"
- :width="width"
- :height="height"
- :x-axis-title="xAxisTitle"
- :y-axis-title="yAxisTitle"
- :x-axis-type="xAxisType"
- />
-</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue
deleted file mode 100644
index e8f54b1fa34..00000000000
--- a/app/assets/javascripts/monitoring/components/charts/column.vue
+++ /dev/null
@@ -1,107 +0,0 @@
-<script>
-import { GlColumnChart } from '@gitlab/ui/dist/charts';
-import { makeDataSeries } from '~/helpers/monitor_helper';
-import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
-import { chartHeight } from '../../constants';
-import { timezones } from '../../format_date';
-import { graphDataValidatorForValues } from '../../utils';
-import { getTimeAxisOptions, getYAxisOptions, getChartGrid } from './options';
-
-export default {
- components: {
- GlColumnChart,
- },
- props: {
- graphData: {
- type: Object,
- required: true,
- validator: graphDataValidatorForValues.bind(null, false),
- },
- timezone: {
- type: String,
- required: false,
- default: timezones.LOCAL,
- },
- },
- data() {
- return {
- width: 0,
- height: chartHeight,
- svgs: {},
- };
- },
- computed: {
- barChartData() {
- return this.graphData.metrics.reduce((acc, query) => {
- const series = makeDataSeries(query.result || [], {
- name: this.formatLegendLabel(query),
- });
-
- return acc.concat(series);
- }, []);
- },
- chartOptions() {
- const xAxis = getTimeAxisOptions({ timezone: this.timezone });
-
- const yAxis = {
- ...getYAxisOptions(this.graphData.yAxis),
- scale: false,
- };
-
- return {
- grid: getChartGrid(),
- xAxis,
- yAxis,
- dataZoom: [this.dataZoomConfig],
- };
- },
- xAxisTitle() {
- return this.graphData.metrics[0].result[0].x_label !== undefined
- ? this.graphData.metrics[0].result[0].x_label
- : '';
- },
- yAxisTitle() {
- return this.chartOptions.yAxis.name;
- },
- xAxisType() {
- return this.graphData.x_type !== undefined ? this.graphData.x_type : 'category';
- },
- dataZoomConfig() {
- const handleIcon = this.svgs['scroll-handle'];
-
- return handleIcon ? { handleIcon } : {};
- },
- },
- created() {
- this.setSvg('scroll-handle');
- },
- methods: {
- formatLegendLabel(query) {
- return query.label;
- },
- setSvg(name) {
- getSvgIconPathContent(name)
- .then((path) => {
- if (path) {
- this.$set(this.svgs, name, `path://${path}`);
- }
- })
- .catch(() => {});
- },
- },
-};
-</script>
-<template>
- <gl-column-chart
- ref="columnChart"
- v-bind="$attrs"
- :responsive="true"
- :bars="barChartData"
- :option="chartOptions"
- :width="width"
- :height="height"
- :x-axis-title="xAxisTitle"
- :y-axis-title="yAxisTitle"
- :x-axis-type="xAxisType"
- />
-</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
deleted file mode 100644
index 6419c45c20c..00000000000
--- a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
+++ /dev/null
@@ -1,37 +0,0 @@
-<script>
-import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-empty-state.svg?raw';
-import SafeHtml from '~/vue_shared/directives/safe_html';
-import { chartHeight } from '../../constants';
-
-export default {
- directives: {
- SafeHtml,
- },
- data() {
- return {
- height: chartHeight,
- };
- },
- computed: {
- svgContainerStyle() {
- return {
- height: `${this.height}px`,
- };
- },
- },
- created() {
- this.chartEmptyStateIllustration = chartEmptyStateIllustration;
- },
- safeHtmlConfig: { ADD_TAGS: ['use'] },
-};
-</script>
-<template>
- <div class="d-flex flex-column justify-content-center">
- <div
- v-safe-html:[$options.safeHtmlConfig]="chartEmptyStateIllustration"
- class="gl-mt-3 svg-w-100 d-flex align-items-center"
- :style="svgContainerStyle"
- ></div>
- <h5 class="text-center gl-mt-3">{{ __('No data to display') }}</h5>
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/gauge.vue b/app/assets/javascripts/monitoring/components/charts/gauge.vue
deleted file mode 100644
index 0477ff19ffe..00000000000
--- a/app/assets/javascripts/monitoring/components/charts/gauge.vue
+++ /dev/null
@@ -1,110 +0,0 @@
-<script>
-import { GlGaugeChart } from '@gitlab/ui/dist/charts';
-import { isFinite, isArray, isInteger } from 'lodash';
-import { getFormatter, SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
-import { graphDataValidatorForValues } from '../../utils';
-import { getValidThresholds } from './options';
-
-export default {
- components: {
- GlGaugeChart,
- },
- props: {
- graphData: {
- type: Object,
- required: true,
- validator: graphDataValidatorForValues.bind(null, true),
- },
- },
- data() {
- return {
- width: 0,
- };
- },
- computed: {
- rangeValues() {
- let min = 0;
- let max = 100;
-
- const { minValue, maxValue } = this.graphData;
-
- const isValidMinMax = () => {
- return isFinite(minValue) && isFinite(maxValue) && minValue < maxValue;
- };
-
- if (isValidMinMax()) {
- min = minValue;
- max = maxValue;
- }
-
- return {
- min,
- max,
- };
- },
- validThresholds() {
- const { mode, values } = this.graphData?.thresholds || {};
- const range = this.rangeValues;
-
- if (!isArray(values)) {
- return [];
- }
-
- return getValidThresholds({ mode, range, values });
- },
- queryResult() {
- return this.graphData?.metrics[0]?.result[0]?.value[1];
- },
- splitValue() {
- const { split } = this.graphData;
- const defaultValue = 10;
-
- return isInteger(split) && split > 0 ? split : defaultValue;
- },
- textValue() {
- const formatFromPanel = this.graphData.format;
- const defaultFormat = SUPPORTED_FORMATS.engineering;
- const format = SUPPORTED_FORMATS[formatFromPanel] ?? defaultFormat;
- const { queryResult } = this;
-
- const formatter = getFormatter(format);
-
- return isFinite(queryResult) ? formatter(queryResult) : '--';
- },
- thresholdsValue() {
- /**
- * If there are no valid thresholds, a default threshold
- * will be set at 90% of the gauge arcs' max value
- */
- const { min, max } = this.rangeValues;
-
- const defaultThresholdValue = [(max - min) * 0.95];
- return this.validThresholds.length ? this.validThresholds : defaultThresholdValue;
- },
- value() {
- /**
- * The gauge chart gitlab-ui component expects a value
- * of type number.
- *
- * So, if the query result is undefined,
- * we pass the gauge chart a value of NaN.
- */
- return this.queryResult || NaN;
- },
- },
-};
-</script>
-<template>
- <gl-gauge-chart
- ref="gaugeChart"
- v-bind="$attrs"
- :responsive="true"
- :value="value"
- :min="rangeValues.min"
- :max="rangeValues.max"
- :thresholds="thresholdsValue"
- :text="textValue"
- :split-number="splitValue"
- :width="width"
- />
-</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
deleted file mode 100644
index 12add274a90..00000000000
--- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue
+++ /dev/null
@@ -1,74 +0,0 @@
-<script>
-import { GlHeatmap } from '@gitlab/ui/dist/charts';
-import { formatDate, timezones, formats } from '../../format_date';
-import { graphDataValidatorForValues } from '../../utils';
-
-export default {
- components: {
- GlHeatmap,
- },
- props: {
- graphData: {
- type: Object,
- required: true,
- validator: graphDataValidatorForValues.bind(null, false),
- },
- timezone: {
- type: String,
- required: false,
- default: timezones.LOCAL,
- },
- },
- data() {
- return {
- width: 0,
- };
- },
- computed: {
- chartData() {
- return this.metrics.result.reduce(
- (acc, result, i) => [...acc, ...result.values.map((value, j) => [i, j, value[1]])],
- [],
- );
- },
- xAxisName() {
- return this.graphData.xLabel || '';
- },
- yAxisName() {
- return this.graphData.y_label || '';
- },
- xAxisLabels() {
- return this.metrics.result.map((res) => Object.values(res.metric)[0]);
- },
- yAxisLabels() {
- return this.result.values.map((val) => {
- const [yLabel] = val;
-
- return formatDate(new Date(yLabel), {
- format: formats.shortTime,
- timezone: this.timezone,
- });
- });
- },
- result() {
- return this.metrics.result[0];
- },
- metrics() {
- return this.graphData.metrics[0];
- },
- },
-};
-</script>
-<template>
- <gl-heatmap
- ref="heatmapChart"
- v-bind="$attrs"
- :responsive="true"
- :data-series="chartData"
- :x-axis-name="xAxisName"
- :y-axis-name="yAxisName"
- :x-axis-labels="xAxisLabels"
- :y-axis-labels="yAxisLabels"
- :width="width"
- />
-</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/options.js b/app/assets/javascripts/monitoring/components/charts/options.js
deleted file mode 100644
index 643550a7144..00000000000
--- a/app/assets/javascripts/monitoring/components/charts/options.js
+++ /dev/null
@@ -1,175 +0,0 @@
-import { isFinite, uniq, sortBy, includes } from 'lodash';
-import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format';
-import { __, s__ } from '~/locale';
-import { thresholdModeTypes } from '../../constants';
-import { formatDate, timezones, formats } from '../../format_date';
-
-const yAxisBoundaryGap = [0.1, 0.1];
-/**
- * Max string length of formatted axis tick
- */
-const maxDataAxisTickLength = 8;
-// Defaults
-const defaultFormat = SUPPORTED_FORMATS.engineering;
-
-const defaultYAxisFormat = defaultFormat;
-const defaultYAxisPrecision = 2;
-
-const defaultTooltipFormat = defaultFormat;
-const defaultTooltipPrecision = 3;
-
-// Give enough space for y-axis with units and name.
-const chartGridLeft = 63; // larger gap than gitlab-ui's default to fit formatted numbers
-const chartGridRight = 10; // half of the scroll-handle icon for data zoom
-const yAxisNameGap = chartGridLeft - 12; // offset the axis label line-height
-
-// Axis options
-
-/**
- * Axis types
- * @see https://echarts.apache.org/en/option.html#xAxis.type
- */
-export const axisTypes = {
- /**
- * Category axis, suitable for discrete category data.
- */
- category: 'category',
- /**
- * Time axis, suitable for continuous time series data.
- */
- time: 'time',
-};
-
-/**
- * Converts .yml parameters to echarts axis options for data axis
- * @param {Object} param - Dashboard .yml definition options
- */
-const getDataAxisOptions = ({ format, precision, name }) => {
- const formatter = getFormatter(format); // default to engineeringNotation, same as gitlab-ui
- return {
- name,
- nameLocation: 'center', // same as gitlab-ui's default
- scale: true,
- axisLabel: {
- formatter: (val) => formatter(val, precision, maxDataAxisTickLength),
- },
- };
-};
-
-/**
- * Converts .yml parameters to echarts y-axis options
- * @param {Object} param - Dashboard .yml definition options
- */
-export const getYAxisOptions = ({
- name = s__('Metrics|Values'),
- format = defaultYAxisFormat,
- precision = defaultYAxisPrecision,
-} = {}) => {
- return {
- nameGap: yAxisNameGap,
- scale: true,
- boundaryGap: yAxisBoundaryGap,
-
- ...getDataAxisOptions({
- name,
- format,
- precision,
- }),
- };
-};
-
-export const getTimeAxisOptions = ({
- timezone = timezones.LOCAL,
- format = formats.shortDateTime,
-} = {}) => ({
- name: __('Time'),
- type: axisTypes.time,
- axisLabel: {
- formatter: (date) => formatDate(date, { format, timezone }),
- },
- axisPointer: {
- snap: false,
- },
-});
-
-// Chart grid
-
-/**
- * Grid with enough room to display chart.
- */
-export const getChartGrid = ({ left = chartGridLeft, right = chartGridRight } = {}) => ({
- left,
- right,
-});
-
-// Tooltip options
-
-export const getTooltipFormatter = ({
- format = defaultTooltipFormat,
- precision = defaultTooltipPrecision,
-} = {}) => {
- const formatter = getFormatter(format);
- return (num) => formatter(num, precision);
-};
-
-// Thresholds
-
-/**
- *
- * Used to find valid thresholds for the gauge chart
- *
- * An array of thresholds values is
- * - duplicate values are removed;
- * - filtered for invalid values;
- * - sorted in ascending order;
- * - only first two values are used.
- */
-export const getValidThresholds = ({ mode, range = {}, values = [] } = {}) => {
- const supportedModes = [thresholdModeTypes.ABSOLUTE, thresholdModeTypes.PERCENTAGE];
- const { min, max } = range;
-
- /**
- * return early if min and max have invalid values
- * or mode has invalid value
- */
- if (!isFinite(min) || !isFinite(max) || min >= max || !includes(supportedModes, mode)) {
- return [];
- }
-
- const uniqueThresholds = uniq(values);
-
- const numberThresholds = uniqueThresholds.filter((threshold) => isFinite(threshold));
-
- const validThresholds = numberThresholds.filter((threshold) => {
- let isValid;
-
- if (mode === thresholdModeTypes.PERCENTAGE) {
- isValid = threshold > 0 && threshold < 100;
- } else if (mode === thresholdModeTypes.ABSOLUTE) {
- isValid = threshold > min && threshold < max;
- }
-
- return isValid;
- });
-
- const transformedThresholds = validThresholds.map((threshold) => {
- let transformedThreshold;
-
- if (mode === 'percentage') {
- transformedThreshold = (threshold / 100) * (max - min);
- } else {
- transformedThreshold = threshold;
- }
-
- return transformedThreshold;
- });
-
- const sortedThresholds = sortBy(transformedThresholds);
-
- const reducedThresholdsArray =
- sortedThresholds.length > 2
- ? [sortedThresholds[0], sortedThresholds[1]]
- : [...sortedThresholds];
-
- return reducedThresholdsArray;
-};
diff --git a/app/assets/javascripts/monitoring/components/charts/single_stat.vue b/app/assets/javascripts/monitoring/components/charts/single_stat.vue
deleted file mode 100644
index 6d6a7af600b..00000000000
--- a/app/assets/javascripts/monitoring/components/charts/single_stat.vue
+++ /dev/null
@@ -1,71 +0,0 @@
-<script>
-import { GlSingleStat } from '@gitlab/ui/dist/charts';
-import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format';
-import { __ } from '~/locale';
-import { graphDataValidatorForValues } from '../../utils';
-
-const defaultPrecision = 2;
-const emptyStateMsg = __('No data to display');
-
-export default {
- components: {
- GlSingleStat,
- },
- inheritAttrs: false,
- props: {
- graphData: {
- type: Object,
- required: true,
- validator: graphDataValidatorForValues.bind(null, true),
- },
- },
- computed: {
- queryInfo() {
- return this.graphData.metrics[0];
- },
- queryMetric() {
- return this.queryInfo.result[0]?.metric;
- },
- queryResult() {
- return this.queryInfo.result[0]?.value[1];
- },
- /**
- * This method formats the query result from a promQL expression
- * allowing a user to format the data in percentile values
- * by using the `maxValue` inner property from the graphData prop
- * @returns {(String)}
- */
- statValue() {
- let formatter;
-
- // if field is present the metric value is not displayed. Hence
- // the early exit without formatting.
- if (this.graphData?.field) {
- return this.queryMetric?.[this.graphData.field] ?? emptyStateMsg;
- }
-
- if (this.graphData?.maxValue) {
- formatter = getFormatter(SUPPORTED_FORMATS.number);
- return formatter(
- (this.queryResult / Number(this.graphData.maxValue)) * 100,
- defaultPrecision,
- );
- }
-
- formatter = getFormatter(SUPPORTED_FORMATS.number);
- return `${formatter(this.queryResult, defaultPrecision)}`;
- },
- unit() {
- return this.graphData?.maxValue ? '%' : this.queryInfo.unit;
- },
- graphTitle() {
- return this.queryInfo.label;
- },
- },
-};
-</script>
-<template>
- <div>
- <gl-single-stat :value="statValue" :title="graphTitle" :unit="unit" variant="success" />
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
deleted file mode 100644
index 0cf39448d6b..00000000000
--- a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
+++ /dev/null
@@ -1,146 +0,0 @@
-<script>
-import { GlStackedColumnChart } from '@gitlab/ui/dist/charts';
-import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
-import { s__ } from '~/locale';
-import { chartHeight, legendLayoutTypes } from '../../constants';
-import { formats, timezones } from '../../format_date';
-import { graphDataValidatorForValues } from '../../utils';
-import { getTimeAxisOptions, axisTypes } from './options';
-
-export default {
- components: {
- GlStackedColumnChart,
- },
- props: {
- graphData: {
- type: Object,
- required: true,
- validator: graphDataValidatorForValues.bind(null, false),
- },
- timezone: {
- type: String,
- required: false,
- default: timezones.LOCAL,
- },
- legendLayout: {
- type: String,
- required: false,
- default: legendLayoutTypes.table,
- },
- legendAverageText: {
- type: String,
- required: false,
- default: s__('Metrics|Avg'),
- },
- legendCurrentText: {
- type: String,
- required: false,
- default: s__('Metrics|Current'),
- },
- legendMaxText: {
- type: String,
- required: false,
- default: s__('Metrics|Max'),
- },
- legendMinText: {
- type: String,
- required: false,
- default: s__('Metrics|Min'),
- },
- },
- data() {
- return {
- width: 0,
- height: chartHeight,
- svgs: {},
- };
- },
- computed: {
- chartData() {
- return this.graphData.metrics
- .map(({ label: name, result }) => {
- // This needs a fix. Not only metrics[0] should be shown.
- // See https://gitlab.com/gitlab-org/gitlab/-/issues/220492
- if (!result || result.length === 0) {
- return [];
- }
- return { name, data: result[0].values.map((val) => val[1]) };
- })
- .slice(0, 1);
- },
- xAxisTitle() {
- return this.graphData.x_label !== undefined ? this.graphData.x_label : '';
- },
- yAxisTitle() {
- return this.graphData.y_label !== undefined ? this.graphData.y_label : '';
- },
- xAxisType() {
- // stacked-column component requires the x-axis to be of type `category`
- return axisTypes.category;
- },
- groupBy() {
- // This needs a fix. Not only metrics[0] should be shown.
- // See https://gitlab.com/gitlab-org/gitlab/-/issues/220492
- const { result } = this.graphData.metrics[0];
- if (!result || result.length === 0) {
- return [];
- }
- return result[0].values.map((val) => val[0]);
- },
- dataZoomConfig() {
- const handleIcon = this.svgs['scroll-handle'];
-
- return handleIcon ? { handleIcon } : {};
- },
- chartOptions() {
- return {
- xAxis: {
- ...getTimeAxisOptions({ timezone: this.timezone, format: formats.shortTime }),
- type: this.xAxisType,
- },
- dataZoom: [this.dataZoomConfig],
- };
- },
- seriesNames() {
- return this.graphData.metrics.map((metric) => metric.label);
- },
- },
- created() {
- this.setSvg('scroll-handle');
- },
- methods: {
- setSvg(name) {
- getSvgIconPathContent(name)
- .then((path) => {
- if (path) {
- this.$set(this.svgs, name, `path://${path}`);
- }
- })
- .catch((e) => {
- // eslint-disable-next-line no-console, @gitlab/require-i18n-strings
- console.error('SVG could not be rendered correctly: ', e);
- });
- },
- },
-};
-</script>
-<template>
- <gl-stacked-column-chart
- ref="chart"
- v-bind="$attrs"
- :responsive="true"
- :bars="chartData"
- :option="chartOptions"
- :x-axis-title="xAxisTitle"
- :y-axis-title="yAxisTitle"
- :x-axis-type="xAxisType"
- :group-by="groupBy"
- :width="width"
- :height="height"
- :legend-layout="legendLayout"
- :legend-average-text="legendAverageText"
- :legend-current-text="legendCurrentText"
- :legend-max-text="legendMaxText"
- :legend-min-text="legendMinText"
- />
-</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
deleted file mode 100644
index b74da3ee89b..00000000000
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ /dev/null
@@ -1,420 +0,0 @@
-<script>
-import { GlLink, GlTooltip, GlIcon } from '@gitlab/ui';
-import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
-import { isEmpty, omit, throttle } from 'lodash';
-import { makeDataSeries } from '~/helpers/monitor_helper';
-import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
-import { s__ } from '~/locale';
-import { panelTypes, chartHeight, lineTypes, lineWidths, legendLayoutTypes } from '../../constants';
-import { formatDate, timezones } from '../../format_date';
-import { graphDataValidatorForValues } from '../../utils';
-import { annotationsYAxis, generateAnnotationsSeries } from './annotations';
-import { getYAxisOptions, getTimeAxisOptions, getChartGrid, getTooltipFormatter } from './options';
-
-export const timestampToISODate = (timestamp) => new Date(timestamp).toISOString();
-
-const THROTTLED_DATAZOOM_WAIT = 1000; // milliseconds
-
-const events = {
- datazoom: 'datazoom',
-};
-
-export default {
- components: {
- GlAreaChart,
- GlLineChart,
- GlTooltip,
- GlChartSeriesLabel,
- GlLink,
- GlIcon,
- },
- inheritAttrs: false,
- props: {
- graphData: {
- type: Object,
- required: true,
- validator: graphDataValidatorForValues.bind(null, false),
- },
- option: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- timeRange: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- seriesConfig: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- deploymentData: {
- type: Array,
- required: false,
- default: () => [],
- },
- annotations: {
- type: Array,
- required: false,
- default: () => [],
- },
- projectPath: {
- type: String,
- required: false,
- default: '',
- },
- height: {
- type: Number,
- required: false,
- default: chartHeight,
- },
- legendLayout: {
- type: String,
- required: false,
- default: legendLayoutTypes.table,
- },
- legendAverageText: {
- type: String,
- required: false,
- default: s__('Metrics|Avg'),
- },
- legendCurrentText: {
- type: String,
- required: false,
- default: s__('Metrics|Current'),
- },
- legendMaxText: {
- type: String,
- required: false,
- default: s__('Metrics|Max'),
- },
- legendMinText: {
- type: String,
- required: false,
- default: s__('Metrics|Min'),
- },
- groupId: {
- type: String,
- required: false,
- default: '',
- },
- timezone: {
- type: String,
- required: false,
- default: timezones.LOCAL,
- },
- },
- data() {
- return {
- tooltip: {
- type: '',
- title: '',
- content: [],
- commitUrl: '',
- sha: '',
- },
- width: 0,
- svgs: {},
- primaryColor: null,
- throttledDatazoom: null,
- };
- },
- computed: {
- chartData() {
- // Transforms & supplements query data to render appropriate labels & styles
- // Input: [{ queryAttributes1 }, { queryAttributes2 }]
- // Output: [{ seriesAttributes1 }, { seriesAttributes2 }]
- return this.graphData.metrics.reduce((acc, query) => {
- const { appearance } = query;
- const lineType =
- appearance && appearance.line && appearance.line.type
- ? appearance.line.type
- : lineTypes.default;
- const lineWidth =
- appearance && appearance.line && appearance.line.width
- ? appearance.line.width
- : lineWidths.default;
- const areaStyle = {
- opacity:
- appearance && appearance.area && typeof appearance.area.opacity === 'number'
- ? appearance.area.opacity
- : undefined,
- };
- const series = makeDataSeries(query.result || [], {
- name: this.formatLegendLabel(query),
- lineStyle: {
- type: lineType,
- width: lineWidth,
- },
- showSymbol: false,
- areaStyle: this.graphData.type === 'area-chart' ? areaStyle : undefined,
- ...this.seriesConfig,
- });
-
- return acc.concat(series);
- }, []);
- },
- chartOptionSeries() {
- // After https://gitlab.com/gitlab-org/gitlab/-/issues/211330 is implemented,
- // this method will have access to annotations data
- return (this.option.series || []).concat(
- generateAnnotationsSeries({
- deployments: this.recentDeployments,
- annotations: this.annotations,
- }),
- );
- },
- chartOptions() {
- const { yAxis, xAxis } = this.option;
- const option = omit(this.option, ['series', 'yAxis', 'xAxis']);
- const xAxisBounds = isEmpty(this.timeRange)
- ? {}
- : {
- min: this.timeRange.start,
- max: this.timeRange.end,
- };
-
- const timeXAxis = {
- ...getTimeAxisOptions({ timezone: this.timezone }),
- ...xAxis,
- ...xAxisBounds,
- };
-
- const dataYAxis = {
- ...getYAxisOptions(this.graphData.yAxis),
- ...yAxis,
- };
-
- return {
- series: this.chartOptionSeries,
- xAxis: timeXAxis,
- yAxis: [dataYAxis, annotationsYAxis],
- grid: getChartGrid(),
- dataZoom: [this.dataZoomConfig],
- ...option,
- };
- },
- dataZoomConfig() {
- const handleIcon = this.svgs['scroll-handle'];
-
- return handleIcon ? { handleIcon } : {};
- },
- /**
- * This method returns the earliest time value in all series of a chart.
- * Takes a chart data with data to populate a timeseries.
- * data should be an array of data points [t, y] where t is a ISO formatted date,
- * and is sorted by t (time).
- * @returns {(String|null)} earliest x value from all series, or null when the
- * chart series data is empty.
- */
- earliestDatapoint() {
- return this.chartData.reduce((acc, series) => {
- const { data } = series;
- const { length } = data;
- if (!length) {
- return acc;
- }
-
- const [first] = data[0];
- const [last] = data[length - 1];
- const seriesEarliest = first < last ? first : last;
-
- return seriesEarliest < acc || acc === null ? seriesEarliest : acc;
- }, null);
- },
- glChartComponent() {
- const chartTypes = {
- [panelTypes.AREA_CHART]: GlAreaChart,
- [panelTypes.LINE_CHART]: GlLineChart,
- };
- return chartTypes[this.graphData.type] || GlAreaChart;
- },
- isMultiSeries() {
- return this.tooltip.content.length > 1;
- },
- recentDeployments() {
- return this.deploymentData.reduce((acc, deployment) => {
- if (deployment.created_at >= this.earliestDatapoint) {
- const { id, created_at: createdAt, sha, ref, tag } = deployment;
- acc.push({
- id,
- createdAt,
- sha,
- commitUrl: `${this.projectPath}/-/commit/${sha}`,
- tag,
- tagUrl: tag ? `${this.tagsPath}/${ref.name}` : null,
- ref: ref.name,
- showDeploymentFlag: false,
- icon: this.svgs.rocket,
- color: this.primaryColor,
- });
- }
-
- return acc;
- }, []);
- },
- tooltipYFormatter() {
- // Use same format as y-axis
- return getTooltipFormatter({ format: this.graphData.yAxis?.format });
- },
- },
- created() {
- this.setSvg('rocket');
- this.setSvg('scroll-handle');
- },
- destroyed() {
- if (this.throttledDatazoom) {
- this.throttledDatazoom.cancel();
- }
- },
- methods: {
- formatLegendLabel(query) {
- return query.label;
- },
- isTooltipOfType(tooltipType, defaultType) {
- return tooltipType === defaultType;
- },
- /**
- * This method is triggered when hovered over a single markPoint.
- *
- * The annotations title timestamp should match the data tooltip
- * title.
- *
- * @params {Object} params markPoint object
- * @returns {Object}
- */
- formatAnnotationsTooltipText(params) {
- return {
- title: formatDate(params.data?.tooltipData?.title, { timezone: this.timezone }),
- content: params.data?.tooltipData?.content,
- };
- },
- formatTooltipText(params) {
- this.tooltip.title = formatDate(params.value, { timezone: this.timezone });
-
- this.tooltip.content = [];
-
- params.seriesData.forEach((dataPoint) => {
- if (dataPoint.value) {
- const [, yVal] = dataPoint.value;
- this.tooltip.type = dataPoint.name;
- if (this.tooltip.type === 'deployments') {
- const { data = {} } = dataPoint;
- this.tooltip.sha = data?.tooltipData?.sha;
- this.tooltip.commitUrl = data?.tooltipData?.commitUrl;
- } else {
- const { seriesName, color, dataIndex } = dataPoint;
-
- this.tooltip.content.push({
- name: seriesName,
- dataIndex,
- value: this.tooltipYFormatter(yVal),
- color,
- });
- }
- }
- });
- },
- setSvg(name) {
- getSvgIconPathContent(name)
- .then((path) => {
- if (path) {
- this.$set(this.svgs, name, `path://${path}`);
- }
- })
- .catch((e) => {
- // eslint-disable-next-line no-console, @gitlab/require-i18n-strings
- console.error('SVG could not be rendered correctly: ', e);
- });
- },
- onChartUpdated(eChart) {
- [this.primaryColor] = eChart.getOption().color;
- },
- onChartCreated(eChart) {
- // Emit a datazoom event that corresponds to the eChart
- // `datazoom` event.
-
- if (this.throttledDatazoom) {
- // Chart can be created multiple times in this component's
- // lifetime, remove previous handlers every time
- // chart is created.
- this.throttledDatazoom.cancel();
- }
-
- // Emitting is throttled to avoid flurries of calls when
- // the user changes or scrolls the zoom bar.
- this.throttledDatazoom = throttle(
- () => {
- const { startValue, endValue } = eChart.getOption().dataZoom[0];
- this.$emit(events.datazoom, {
- start: timestampToISODate(startValue),
- end: timestampToISODate(endValue),
- });
- },
- THROTTLED_DATAZOOM_WAIT,
- {
- leading: false,
- },
- );
-
- // eslint-disable-next-line @gitlab/no-global-event-off
- eChart.off('datazoom');
- eChart.on('datazoom', this.throttledDatazoom);
- },
- },
-};
-</script>
-
-<template>
- <component
- :is="glChartComponent"
- ref="chart"
- v-bind="$attrs"
- :responsive="true"
- :group-id="groupId"
- :data="chartData"
- :option="chartOptions"
- :format-tooltip-text="formatTooltipText"
- :format-annotations-tooltip-text="formatAnnotationsTooltipText"
- :width="width"
- :height="height"
- :legend-layout="legendLayout"
- :legend-average-text="legendAverageText"
- :legend-current-text="legendCurrentText"
- :legend-max-text="legendMaxText"
- :legend-min-text="legendMinText"
- @created="onChartCreated"
- @updated="onChartUpdated"
- >
- <template #tooltip-title>
- <template v-if="tooltip.type === 'deployments'">
- {{ __('Deployed') }}
- </template>
- <div v-else class="text-nowrap">
- {{ tooltip.title }}
- </div>
- </template>
- <template #tooltip-content>
- <div v-if="tooltip.type === 'deployments'" class="d-flex align-items-center">
- <gl-icon name="commit" class="mr-2" />
- <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
- </div>
- <template v-else>
- <div
- v-for="(content, key) in tooltip.content"
- :key="key"
- class="d-flex justify-content-between"
- >
- <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
- {{ content.name }}
- </gl-chart-series-label>
- <div class="gl-ml-7">
- {{ content.value }}
- </div>
- </div>
- </template>
- </template>
- </component>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue
deleted file mode 100644
index 10178366db5..00000000000
--- a/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue
+++ /dev/null
@@ -1,66 +0,0 @@
-<script>
-import { GlButton, GlModal, GlSprintf } from '@gitlab/ui';
-import { isSafeURL } from '~/lib/utils/url_utility';
-import { s__ } from '~/locale';
-
-export default {
- components: { GlButton, GlModal, GlSprintf },
- props: {
- modalId: {
- type: String,
- required: true,
- },
- projectPath: {
- type: String,
- required: true,
- validator: isSafeURL,
- },
- addDashboardDocumentationPath: {
- type: String,
- required: true,
- },
- },
- methods: {
- cancelHandler() {
- this.$refs.modal.hide();
- },
- },
- i18n: {
- titleText: s__('Metrics|Create your dashboard configuration file'),
- mainText: s__(
- 'Metrics|To create a new dashboard, add a new YAML file to %{codeStart}.gitlab/dashboards%{codeEnd} at the root of this project.',
- ),
- },
-};
-</script>
-
-<template>
- <gl-modal ref="modal" :modal-id="modalId" :title="$options.i18n.titleText">
- <p>
- <gl-sprintf :message="$options.i18n.mainText">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </p>
- <template #modal-footer>
- <gl-button category="secondary" @click="cancelHandler">{{ s__('Metrics|Cancel') }}</gl-button>
- <gl-button
- category="secondary"
- variant="confirm"
- target="_blank"
- :href="addDashboardDocumentationPath"
- data-testid="create-dashboard-modal-docs-button"
- >
- {{ s__('Metrics|View documentation') }}
- </gl-button>
- <gl-button
- variant="confirm"
- data-testid="create-dashboard-modal-repo-button"
- :href="projectPath"
- >
- {{ s__('Metrics|Open repository') }}
- </gl-button>
- </template>
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
deleted file mode 100644
index cfc20b7b95f..00000000000
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ /dev/null
@@ -1,510 +0,0 @@
-<script>
-import {
- GlButton,
- GlModalDirective,
- GlTooltipDirective,
- GlIcon,
- GlAlert,
- GlSprintf,
- GlLink,
-} from '@gitlab/ui';
-import VueDraggable from 'vuedraggable';
-import { mapActions, mapState, mapGetters } from 'vuex';
-import { createAlert } from '~/alert';
-import invalidUrl from '~/lib/utils/invalid_url';
-import { ESC_KEY } from '~/lib/utils/keys';
-import { mergeUrlParams, updateHistory } from '~/lib/utils/url_utility';
-import { s__ } from '~/locale';
-import { defaultTimeRange } from '~/vue_shared/constants';
-import TrackEventDirective from '~/vue_shared/directives/track_event';
-import { metricStates } from '../constants';
-import {
- timeRangeFromUrl,
- panelToUrl,
- expandedPanelPayloadFromUrl,
- convertVariablesForURL,
-} from '../utils';
-import DashboardHeader from './dashboard_header.vue';
-import DashboardPanel from './dashboard_panel.vue';
-
-import EmptyState from './empty_state.vue';
-import GraphGroup from './graph_group.vue';
-import GroupEmptyState from './group_empty_state.vue';
-import LinksSection from './links_section.vue';
-import VariablesSection from './variables_section.vue';
-
-export default {
- components: {
- VueDraggable,
- DashboardHeader,
- DashboardPanel,
- GlIcon,
- GlButton,
- GraphGroup,
- EmptyState,
- GroupEmptyState,
- VariablesSection,
- LinksSection,
- GlAlert,
- GlSprintf,
- GlLink,
- },
- directives: {
- GlModal: GlModalDirective,
- GlTooltip: GlTooltipDirective,
- TrackEvent: TrackEventDirective,
- },
- props: {
- hasMetrics: {
- type: Boolean,
- required: false,
- default: true,
- },
- showHeader: {
- type: Boolean,
- required: false,
- default: true,
- },
- showPanels: {
- type: Boolean,
- required: false,
- default: true,
- },
- documentationPath: {
- type: String,
- required: true,
- },
- settingsPath: {
- type: String,
- required: true,
- },
- clustersPath: {
- type: String,
- required: true,
- },
- tagsPath: {
- type: String,
- required: true,
- },
- defaultBranch: {
- type: String,
- required: false,
- default: '',
- },
- emptyGettingStartedSvgPath: {
- type: String,
- required: true,
- },
- emptyLoadingSvgPath: {
- type: String,
- required: true,
- },
- emptyNoDataSvgPath: {
- type: String,
- required: true,
- },
- emptyNoDataSmallSvgPath: {
- type: String,
- required: true,
- },
- emptyUnableToConnectSvgPath: {
- type: String,
- required: true,
- },
- customMetricsAvailable: {
- type: Boolean,
- required: false,
- default: false,
- },
- customMetricsPath: {
- type: String,
- required: false,
- default: invalidUrl,
- },
- validateQueryPath: {
- type: String,
- required: false,
- default: invalidUrl,
- },
- smallEmptyState: {
- type: Boolean,
- required: false,
- default: false,
- },
- rearrangePanelsAvailable: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- selectedTimeRange: timeRangeFromUrl() || defaultTimeRange,
- isRearrangingPanels: false,
- originalDocumentTitle: document.title,
- hoveredPanel: '',
- isDeprecationNoticeDismissed: false,
- };
- },
- computed: {
- ...mapState('monitoringDashboard', [
- 'dashboard',
- 'emptyState',
- 'expandedPanel',
- 'variables',
- 'links',
- 'currentDashboard',
- 'hasDashboardValidationWarnings',
- ]),
- ...mapGetters('monitoringDashboard', ['selectedDashboard', 'getMetricStates']),
- shouldShowEmptyState() {
- return Boolean(this.emptyState);
- },
- shouldShowVariablesSection() {
- return Boolean(this.variables.length);
- },
- shouldShowLinksSection() {
- return Object.keys(this.links).length > 0;
- },
- },
- watch: {
- dashboard(newDashboard) {
- try {
- const expandedPanel = expandedPanelPayloadFromUrl(newDashboard);
- if (expandedPanel) {
- this.setExpandedPanel(expandedPanel);
- }
- } catch {
- createAlert({
- message: s__(
- 'Metrics|Link contains invalid chart information, please verify the link to see the expanded panel.',
- ),
- });
- }
- },
- expandedPanel: {
- handler({ group, panel }) {
- const dashboardPath = this.currentDashboard || this.selectedDashboard?.path;
- updateHistory({
- url: panelToUrl(dashboardPath, convertVariablesForURL(this.variables), group, panel),
- title: document.title,
- });
- },
- deep: true,
- },
- selectedDashboard(dashboard) {
- this.prependToDocumentTitle(dashboard?.display_name);
- },
- hasDashboardValidationWarnings(hasWarnings) {
- /**
- * This watcher is set for future SPA behaviour of the dashboard
- */
- if (hasWarnings) {
- createAlert({
- message: s__(
- 'Metrics|Your dashboard schema is invalid. Edit the dashboard to correct the YAML schema.',
- ),
-
- type: 'warning',
- });
- }
- },
- },
- mounted() {
- if (!this.hasMetrics) {
- this.setGettingStartedEmptyState();
- } else {
- this.setTimeRange(this.selectedTimeRange);
- this.fetchData();
- }
- },
- methods: {
- ...mapActions('monitoringDashboard', [
- 'setTimeRange',
- 'fetchData',
- 'setGettingStartedEmptyState',
- 'setPanelGroupMetrics',
- 'setExpandedPanel',
- 'clearExpandedPanel',
- ]),
- updatePanels(key, panels) {
- this.setPanelGroupMetrics({
- panels,
- key,
- });
- },
- removePanel(key, panels, graphIndex) {
- this.setPanelGroupMetrics({
- panels: panels.filter((v, i) => i !== graphIndex),
- key,
- });
- },
- generatePanelUrl(groupKey, panel) {
- const dashboardPath = this.currentDashboard || this.selectedDashboard?.path;
- return panelToUrl(dashboardPath, convertVariablesForURL(this.variables), groupKey, panel);
- },
- /**
- * Return a single empty state for a group.
- *
- * If all states are the same a single state is returned to be displayed
- * Except if the state is OK, in which case the group is displayed.
- *
- * @param {String} groupKey - Identifier for group
- * @returns {String} state code from `metricStates`
- */
- groupSingleEmptyState(groupKey) {
- const states = this.getMetricStates(groupKey);
- if (states.length === 1 && states[0] !== metricStates.OK) {
- return states[0];
- }
- return null;
- },
- /**
- * Return true if the entire group is loading.
- * @param {String} groupKey - Identifier for group
- * @returns {boolean}
- */
- isGroupLoading(groupKey) {
- return this.groupSingleEmptyState(groupKey) === metricStates.LOADING;
- },
- /**
- * A group should be not collapsed if any metric is loaded (OK)
- *
- * @param {String} groupKey - Identifier for group
- * @returns {Boolean} If the group should be collapsed
- */
- collapseGroup(groupKey) {
- // Collapse group if no data is available
- return !this.getMetricStates(groupKey).includes(metricStates.OK);
- },
- prependToDocumentTitle(text) {
- if (text) {
- document.title = `${text} · ${this.originalDocumentTitle}`;
- }
- },
- onTimeRangeZoom({ start, end }) {
- updateHistory({
- url: mergeUrlParams({ start, end }, window.location.href),
- title: document.title,
- });
- this.selectedTimeRange = { start, end };
- // keep the current dashboard time range
- // in sync with the Vuex store
- this.setTimeRange(this.selectedTimeRange);
- },
- onExpandPanel(group, panel) {
- this.setExpandedPanel({ group, panel });
- },
- onGoBack() {
- this.clearExpandedPanel();
- },
- onKeyup(event) {
- const { key } = event;
- if (key === ESC_KEY) {
- this.clearExpandedPanel();
- }
- },
- onSetRearrangingPanels(isRearrangingPanels) {
- this.isRearrangingPanels = isRearrangingPanels;
- },
- onDateTimePickerInvalid() {
- createAlert({
- message: s__(
- 'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.',
- ),
- });
- // As a fallback, switch to default time range instead
- this.selectedTimeRange = defaultTimeRange;
- },
- isPanelHalfWidth(panelIndex, totalPanels) {
- /**
- * A single panel on a row should take the full width of its parent.
- * All others should have half the width their parent.
- */
- const isNumberOfPanelsEven = totalPanels % 2 === 0;
- const isLastPanel = panelIndex === totalPanels - 1;
-
- return isNumberOfPanelsEven || !isLastPanel;
- },
- /**
- * TODO: Investigate this to utilize the eventBus from Vue
- * The intention behind this cleanup is to allow for better tests
- * as well as use the correct eventBus facilities that are compatible
- * with Vue 3
- * https://gitlab.com/gitlab-org/gitlab/-/issues/225583
- */
- //
- runShortcut(actionToRun) {
- const panel = this.$refs[this.hoveredPanel];
-
- if (!panel) return;
-
- const [panelInstance] = panel;
- panelInstance[actionToRun]();
- },
- setHoveredPanel(groupKey, graphIndex) {
- this.hoveredPanel = `dashboard-panel-${groupKey}-${graphIndex}`;
- },
- clearHoveredPanel() {
- this.hoveredPanel = '';
- },
- },
- i18n: {
- collapsePanelLabel: s__('Metrics|Collapse panel'),
- collapsePanelTooltip: s__('Metrics|Collapse panel (Esc)'),
- },
-};
-</script>
-<template>
- <div class="prometheus-graphs" data-testid="prometheus-graphs">
- <div>
- <gl-alert
- v-if="!isDeprecationNoticeDismissed"
- :title="__('Feature deprecation')"
- class="mb-3"
- variant="warning"
- @dismiss="isDeprecationNoticeDismissed = true"
- >
- <gl-sprintf
- :message="s__('Deprecations|The metrics feature was deprecated in GitLab 14.7.')"
- >
- <template #epic="{ content }">
- <gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/7188" target="_blank">{{
- content
- }}</gl-link>
- </template>
- </gl-sprintf>
- <gl-sprintf
- :message="
- s__(
- 'Deprecations|For information on a possible replacement %{epicStart} learn more about Opstrace %{epicEnd}.',
- )
- "
- >
- <template #epic="{ content }">
- <gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/6976" target="_blank">{{
- content
- }}</gl-link>
- </template>
- </gl-sprintf>
- </gl-alert>
- </div>
- <dashboard-header
- v-if="showHeader"
- ref="prometheusGraphsHeader"
- class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light"
- :default-branch="defaultBranch"
- :rearrange-panels-available="rearrangePanelsAvailable"
- :custom-metrics-available="customMetricsAvailable"
- :custom-metrics-path="customMetricsPath"
- :validate-query-path="validateQueryPath"
- :is-rearranging-panels="isRearrangingPanels"
- :selected-time-range="selectedTimeRange"
- @dateTimePickerInvalid="onDateTimePickerInvalid"
- @setRearrangingPanels="onSetRearrangingPanels"
- />
- <template v-if="!shouldShowEmptyState">
- <variables-section v-if="shouldShowVariablesSection" />
- <links-section v-if="shouldShowLinksSection" />
- <dashboard-panel
- v-show="expandedPanel.panel"
- ref="expandedPanel"
- :settings-path="settingsPath"
- :clipboard-text="generatePanelUrl(expandedPanel.group, expandedPanel.panel)"
- :graph-data="expandedPanel.panel"
- :height="600"
- @timerangezoom="onTimeRangeZoom"
- >
- <template #top-left>
- <gl-button
- ref="goBackBtn"
- v-gl-tooltip
- class="mr-3 my-3"
- :title="$options.i18n.collapsePanelTooltip"
- @click="onGoBack"
- >
- {{ $options.i18n.collapsePanelLabel }}
- </gl-button>
- </template>
- </dashboard-panel>
-
- <div v-show="!expandedPanel.panel">
- <graph-group
- v-for="groupData in dashboard.panelGroups"
- :key="`${groupData.group}.${groupData.priority}`"
- :name="groupData.group"
- :show-panels="showPanels"
- :is-loading="isGroupLoading(groupData.key)"
- :collapse-group="collapseGroup(groupData.key)"
- >
- <vue-draggable
- v-if="!groupSingleEmptyState(groupData.key)"
- :value="groupData.panels"
- group="metrics-dashboard"
- :component-data="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- attrs: { class: 'row mx-0 w-100' },
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- :disabled="!isRearrangingPanels"
- @input="updatePanels(groupData.key, $event)"
- >
- <div
- v-for="(graphData, graphIndex) in groupData.panels"
- :key="`dashboard-panel-${graphIndex}`"
- data-testid="dashboard-panel-layout-wrapper"
- class="col-12 px-2 mb-2 draggable"
- :class="{
- 'draggable-enabled': isRearrangingPanels,
- 'col-lg-6': isPanelHalfWidth(graphIndex, groupData.panels.length),
- }"
- @mouseover="setHoveredPanel(groupData.key, graphIndex)"
- @mouseout="clearHoveredPanel"
- >
- <div class="position-relative draggable-panel js-draggable-panel">
- <div
- v-if="isRearrangingPanels"
- class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
- @click="removePanel(groupData.key, groupData.panels, graphIndex)"
- >
- <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')">
- <gl-icon name="close" />
- </a>
- </div>
-
- <dashboard-panel
- :ref="`dashboard-panel-${groupData.key}-${graphIndex}`"
- :settings-path="settingsPath"
- :clipboard-text="generatePanelUrl(groupData.group, graphData)"
- :graph-data="graphData"
- @timerangezoom="onTimeRangeZoom"
- @expand="onExpandPanel(groupData.group, graphData)"
- />
- </div>
- </div>
- </vue-draggable>
- <div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6">
- <group-empty-state
- ref="empty-group"
- :documentation-path="documentationPath"
- :settings-path="settingsPath"
- :selected-state="groupSingleEmptyState(groupData.key)"
- :svg-path="emptyNoDataSmallSvgPath"
- />
- </div>
- </graph-group>
- </div>
- </template>
- <empty-state
- v-else
- :selected-state="emptyState"
- :documentation-path="documentationPath"
- :settings-path="settingsPath"
- :clusters-path="clustersPath"
- :empty-getting-started-svg-path="emptyGettingStartedSvgPath"
- :empty-loading-svg-path="emptyLoadingSvgPath"
- :empty-no-data-svg-path="emptyNoDataSvgPath"
- :empty-no-data-small-svg-path="emptyNoDataSmallSvgPath"
- :empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath"
- :compact="smallEmptyState"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue
deleted file mode 100644
index 29ce8572e9a..00000000000
--- a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue
+++ /dev/null
@@ -1,291 +0,0 @@
-<script>
-import {
- GlButton,
- GlDropdown,
- GlDropdownDivider,
- GlDropdownItem,
- GlModal,
- GlIcon,
- GlModalDirective,
- GlTooltipDirective,
-} from '@gitlab/ui';
-import { mapState, mapGetters, mapActions } from 'vuex';
-import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue';
-import invalidUrl from '~/lib/utils/invalid_url';
-import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
-import { s__ } from '~/locale';
-import TrackEventDirective from '~/vue_shared/directives/track_event';
-import { PANEL_NEW_PAGE } from '../router/constants';
-import { getAddMetricTrackingOptions } from '../utils';
-import CreateDashboardModal from './create_dashboard_modal.vue';
-import DuplicateDashboardModal from './duplicate_dashboard_modal.vue';
-
-export default {
- components: {
- GlButton,
- GlDropdown,
- GlDropdownDivider,
- GlDropdownItem,
- GlModal,
- GlIcon,
- DuplicateDashboardModal,
- CreateDashboardModal,
- CustomMetricsFormFields,
- },
- directives: {
- GlModal: GlModalDirective,
- GlTooltip: GlTooltipDirective,
- TrackEvent: TrackEventDirective,
- },
- props: {
- addingMetricsAvailable: {
- type: Boolean,
- required: false,
- default: false,
- },
- customMetricsPath: {
- type: String,
- required: false,
- default: invalidUrl,
- },
- validateQueryPath: {
- type: String,
- required: false,
- default: invalidUrl,
- },
- defaultBranch: {
- type: String,
- required: true,
- },
- isOotbDashboard: {
- type: Boolean,
- required: true,
- },
- },
- data() {
- return { customMetricsFormIsValid: null };
- },
- computed: {
- ...mapState('monitoringDashboard', [
- 'projectPath',
- 'isUpdatingStarredValue',
- 'addDashboardDocumentationPath',
- ]),
- ...mapGetters('monitoringDashboard', ['selectedDashboard']),
- isOutOfTheBoxDashboard() {
- return this.selectedDashboard?.out_of_the_box_dashboard;
- },
- isMenuItemEnabled() {
- return {
- addPanel: !this.isOotbDashboard,
- createDashboard: Boolean(this.projectPath),
- editDashboard: this.selectedDashboard?.can_edit,
- };
- },
- isMenuItemShown() {
- return {
- duplicateDashboard: this.isOutOfTheBoxDashboard,
- };
- },
- newPanelPageLocation() {
- // Retains params/query if any
- const { params, query } = this.$route ?? {};
- return { name: PANEL_NEW_PAGE, params, query };
- },
- },
- methods: {
- ...mapActions('monitoringDashboard', ['toggleStarredValue']),
- setFormValidity(isValid) {
- this.customMetricsFormIsValid = isValid;
- },
- hideAddMetricModal() {
- this.$refs.addMetricModal.hide();
- },
- getAddMetricTrackingOptions,
- submitCustomMetricsForm() {
- this.$refs.customMetricsForm.submit();
- },
- selectDashboard(dashboard) {
- // Once the sidebar See metrics link is updated to the new URL,
- // this sort of hardcoding will not be necessary.
- // https://gitlab.com/gitlab-org/gitlab/-/issues/229277
- const baseURL = `${this.projectPath}/-/metrics`;
- const dashboardPath = encodeURIComponent(
- dashboard.out_of_the_box_dashboard ? dashboard.path : dashboard.display_name,
- );
- redirectTo(`${baseURL}/${dashboardPath}`); // eslint-disable-line import/no-deprecated
- },
- },
-
- modalIds: {
- addMetric: 'addMetric',
- createDashboard: 'createDashboard',
- duplicateDashboard: 'duplicateDashboard',
- },
- i18n: {
- actionsMenu: s__('Metrics|More actions'),
- duplicateDashboard: s__('Metrics|Duplicate current dashboard'),
- starDashboard: s__('Metrics|Star dashboard'),
- unstarDashboard: s__('Metrics|Unstar dashboard'),
- addMetric: s__('Metrics|Add metric'),
- addPanel: s__('Metrics|Add panel'),
- addPanelInfo: s__('Metrics|Duplicate this dashboard to add panel or edit dashboard YAML.'),
- editDashboardInfo: s__('Metrics|Duplicate this dashboard to add panel or edit dashboard YAML.'),
- editDashboard: s__('Metrics|Edit dashboard YAML'),
- createDashboard: s__('Metrics|Create new dashboard'),
- },
-};
-</script>
-
-<template>
- <!--
- This component should be replaced with a variant developed
- as part of https://gitlab.com/gitlab-org/gitlab-ui/-/issues/936
- The variant will create a dropdown with an icon, no text and no caret
- -->
- <gl-dropdown
- v-gl-tooltip
- data-testid="actions-menu"
- right
- no-caret
- toggle-class="gl-px-3!"
- :title="$options.i18n.actionsMenu"
- >
- <template #button-content>
- <gl-icon class="gl-mr-0!" name="ellipsis_v" />
- </template>
-
- <template v-if="addingMetricsAvailable">
- <gl-dropdown-item
- v-gl-modal="$options.modalIds.addMetric"
- data-qa-selector="add_metric_button"
- data-testid="add-metric-item"
- >
- {{ $options.i18n.addMetric }}
- </gl-dropdown-item>
- <gl-modal
- ref="addMetricModal"
- :modal-id="$options.modalIds.addMetric"
- :title="$options.i18n.addMetric"
- data-testid="add-metric-modal"
- >
- <form ref="customMetricsForm" :action="customMetricsPath" method="post">
- <custom-metrics-form-fields
- :validate-query-path="validateQueryPath"
- form-operation="post"
- @formValidation="setFormValidity"
- />
- </form>
- <template #modal-footer>
- <div>
- <gl-button @click="hideAddMetricModal">
- {{ __('Cancel') }}
- </gl-button>
- <gl-button
- v-track-event="getAddMetricTrackingOptions()"
- data-testid="add-metric-modal-submit-button"
- :disabled="!customMetricsFormIsValid"
- variant="confirm"
- @click="submitCustomMetricsForm"
- >
- {{ __('Save changes') }}
- </gl-button>
- </div>
- </template>
- </gl-modal>
- </template>
-
- <gl-dropdown-item
- v-if="isMenuItemEnabled.addPanel"
- data-testid="add-panel-item-enabled"
- :to="newPanelPageLocation"
- >
- {{ $options.i18n.addPanel }}
- </gl-dropdown-item>
-
- <!--
- wrapper for tooltip as button can be `disabled`
- https://bootstrap-vue.org/docs/components/tooltip#disabled-elements
- -->
- <div v-else v-gl-tooltip :title="$options.i18n.addPanelInfo">
- <gl-dropdown-item
- :alt="$options.i18n.addPanelInfo"
- :to="newPanelPageLocation"
- data-testid="add-panel-item-disabled"
- disabled
- class="gl-cursor-not-allowed"
- >
- <span class="gl-text-gray-400">{{ $options.i18n.addPanel }}</span>
- </gl-dropdown-item>
- </div>
-
- <gl-dropdown-item
- v-if="isMenuItemEnabled.editDashboard"
- :href="selectedDashboard ? selectedDashboard.project_blob_path : null"
- data-testid="edit-dashboard-item-enabled"
- >
- {{ $options.i18n.editDashboard }}
- </gl-dropdown-item>
-
- <!--
- wrapper for tooltip as button can be `disabled`
- https://bootstrap-vue.org/docs/components/tooltip#disabled-elements
- -->
- <div v-else v-gl-tooltip :title="$options.i18n.editDashboardInfo">
- <gl-dropdown-item
- :alt="$options.i18n.editDashboardInfo"
- :href="selectedDashboard ? selectedDashboard.project_blob_path : null"
- data-testid="edit-dashboard-item-disabled"
- disabled
- class="gl-cursor-not-allowed"
- >
- <span class="gl-text-gray-400">{{ $options.i18n.editDashboard }}</span>
- </gl-dropdown-item>
- </div>
-
- <template v-if="isMenuItemShown.duplicateDashboard">
- <gl-dropdown-item
- v-gl-modal="$options.modalIds.duplicateDashboard"
- data-testid="duplicate-dashboard-item"
- >
- {{ $options.i18n.duplicateDashboard }}
- </gl-dropdown-item>
-
- <duplicate-dashboard-modal
- :default-branch="defaultBranch"
- :modal-id="$options.modalIds.duplicateDashboard"
- data-testid="duplicate-dashboard-modal"
- @dashboardDuplicated="selectDashboard"
- />
- </template>
-
- <gl-dropdown-item
- v-if="selectedDashboard"
- data-testid="star-dashboard-item"
- :disabled="isUpdatingStarredValue"
- @click="toggleStarredValue()"
- >
- {{ selectedDashboard.starred ? $options.i18n.unstarDashboard : $options.i18n.starDashboard }}
- </gl-dropdown-item>
-
- <gl-dropdown-divider />
-
- <gl-dropdown-item
- v-gl-modal="$options.modalIds.createDashboard"
- data-testid="create-dashboard-item"
- :disabled="!isMenuItemEnabled.createDashboard"
- :class="{ 'monitoring-actions-item-disabled': !isMenuItemEnabled.createDashboard }"
- >
- {{ $options.i18n.createDashboard }}
- </gl-dropdown-item>
-
- <template v-if="isMenuItemEnabled.createDashboard">
- <create-dashboard-modal
- data-testid="create-dashboard-modal"
- :add-dashboard-documentation-path="addDashboardDocumentationPath"
- :modal-id="$options.modalIds.createDashboard"
- :project-path="projectPath"
- />
- </template>
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue
deleted file mode 100644
index f4dc29f2184..00000000000
--- a/app/assets/javascripts/monitoring/components/dashboard_header.vue
+++ /dev/null
@@ -1,294 +0,0 @@
-<script>
-import {
- GlButton,
- GlDropdown,
- GlLoadingIcon,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlSearchBoxByType,
- GlModalDirective,
- GlTooltipDirective,
- GlIcon,
-} from '@gitlab/ui';
-import { debounce } from 'lodash';
-import { mapActions, mapState, mapGetters } from 'vuex';
-import invalidUrl from '~/lib/utils/invalid_url';
-import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
-import { s__ } from '~/locale';
-import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
-
-import { timeRanges } from '~/vue_shared/constants';
-import { timezones } from '../format_date';
-import { timeRangeToUrl } from '../utils';
-import ActionsMenu from './dashboard_actions_menu.vue';
-import DashboardsDropdown from './dashboards_dropdown.vue';
-import RefreshButton from './refresh_button.vue';
-
-export default {
- i18n: {
- metricsSettings: s__('Metrics|Metrics Settings'),
- },
- components: {
- GlIcon,
- GlButton,
- GlDropdown,
- GlLoadingIcon,
- GlDropdownItem,
- GlDropdownSectionHeader,
-
- GlSearchBoxByType,
-
- DateTimePicker,
- DashboardsDropdown,
- RefreshButton,
-
- ActionsMenu,
- },
- directives: {
- GlModal: GlModalDirective,
- GlTooltip: GlTooltipDirective,
- },
- props: {
- defaultBranch: {
- type: String,
- required: true,
- },
- rearrangePanelsAvailable: {
- type: Boolean,
- required: false,
- default: false,
- },
- customMetricsAvailable: {
- type: Boolean,
- required: false,
- default: false,
- },
- customMetricsPath: {
- type: String,
- required: false,
- default: invalidUrl,
- },
- validateQueryPath: {
- type: String,
- required: false,
- default: invalidUrl,
- },
- isRearrangingPanels: {
- type: Boolean,
- required: true,
- },
- selectedTimeRange: {
- type: Object,
- required: true,
- },
- },
- computed: {
- ...mapState('monitoringDashboard', [
- 'emptyState',
- 'environmentsLoading',
- 'currentEnvironmentName',
- 'dashboardTimezone',
- 'projectPath',
- 'canAccessOperationsSettings',
- 'operationsSettingsPath',
- 'currentDashboard',
- 'externalDashboardUrl',
- ]),
- ...mapGetters('monitoringDashboard', ['selectedDashboard', 'filteredEnvironments']),
- shouldShowEmptyState() {
- return Boolean(this.emptyState);
- },
- shouldShowEnvironmentsDropdownNoMatchedMsg() {
- return !this.environmentsLoading && this.filteredEnvironments.length === 0;
- },
- addingMetricsAvailable() {
- return (
- this.customMetricsAvailable &&
- !this.shouldShowEmptyState &&
- // Custom metrics only available on system dashboards because
- // they are stored in the database. This can be improved. See:
- // https://gitlab.com/gitlab-org/gitlab/-/issues/28241
- this.selectedDashboard?.out_of_the_box_dashboard
- );
- },
- showRearrangePanelsBtn() {
- return !this.shouldShowEmptyState && this.rearrangePanelsAvailable;
- },
- environmentDropdownText() {
- return this.currentEnvironmentName ?? '';
- },
- displayUtc() {
- return this.dashboardTimezone === timezones.UTC;
- },
- shouldShowSettingsButton() {
- return this.canAccessOperationsSettings && this.operationsSettingsPath;
- },
- isOOTBDashboard() {
- return this.selectedDashboard?.out_of_the_box_dashboard ?? false;
- },
- },
- methods: {
- ...mapActions('monitoringDashboard', ['filterEnvironments']),
- selectDashboard(dashboard) {
- // Once the sidebar See metrics link is updated to the new URL,
- // this sort of hardcoding will not be necessary.
- // https://gitlab.com/gitlab-org/gitlab/-/issues/229277
- const baseURL = `${this.projectPath}/-/metrics`;
- const dashboardPath = encodeURIComponent(
- dashboard.out_of_the_box_dashboard ? dashboard.path : dashboard.display_name,
- );
- redirectTo(`${baseURL}/${dashboardPath}`); // eslint-disable-line import/no-deprecated
- },
- debouncedEnvironmentsSearch: debounce(function environmentsSearchOnInput(searchTerm) {
- this.filterEnvironments(searchTerm);
- }, 500),
- onDateTimePickerInput(timeRange) {
- redirectTo(timeRangeToUrl(timeRange)); // eslint-disable-line import/no-deprecated
- },
- onDateTimePickerInvalid() {
- this.$emit('dateTimePickerInvalid');
- },
-
- toggleRearrangingPanels() {
- this.$emit('setRearrangingPanels', !this.isRearrangingPanels);
- },
- getEnvironmentPath(environment) {
- // Once the sidebar See metrics link is updated to the new URL,
- // this sort of hardcoding will not be necessary.
- // https://gitlab.com/gitlab-org/gitlab/-/issues/229277
- const baseURL = `${this.projectPath}/-/metrics`;
- const dashboardPath = encodeURIComponent(this.currentDashboard || '');
- // The environment_metrics_spec.rb requires the URL to not have
- // slashes. Hence, this additional check.
- const url = dashboardPath ? `${baseURL}/${dashboardPath}` : baseURL;
- return mergeUrlParams({ environment }, url);
- },
- },
- timeRanges,
-};
-</script>
-
-<template>
- <div ref="prometheusGraphsHeader">
- <div class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block">
- <dashboards-dropdown
- id="monitor-dashboards-dropdown"
- class="flex-grow-1"
- toggle-class="dropdown-menu-toggle"
- :default-branch="defaultBranch"
- @selectDashboard="selectDashboard"
- />
- </div>
-
- <span aria-hidden="true" class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"></span>
-
- <div class="mb-2 pr-2 d-flex d-sm-block">
- <gl-dropdown
- id="monitor-environments-dropdown"
- ref="monitorEnvironmentsDropdown"
- class="flex-grow-1"
- data-testid="environments-dropdown"
- toggle-class="dropdown-menu-toggle"
- menu-class="monitor-environment-dropdown-menu"
- :text="environmentDropdownText"
- >
- <div class="d-flex flex-column overflow-hidden">
- <gl-dropdown-section-header>{{ __('Environment') }}</gl-dropdown-section-header>
- <gl-search-box-by-type @input="debouncedEnvironmentsSearch" />
-
- <gl-loading-icon v-if="environmentsLoading" size="sm" :inline="true" />
- <div v-else class="flex-fill overflow-auto">
- <gl-dropdown-item
- v-for="environment in filteredEnvironments"
- :key="environment.id"
- is-check-item
- :is-checked="environment.name === currentEnvironmentName"
- :href="getEnvironmentPath(environment.id)"
- >
- {{ environment.name }}
- </gl-dropdown-item>
- </div>
- <div
- v-show="shouldShowEnvironmentsDropdownNoMatchedMsg"
- ref="monitorEnvironmentsDropdownMsg"
- class="text-secondary no-matches-message"
- >
- {{ __('No matching results') }}
- </div>
- </div>
- </gl-dropdown>
- </div>
-
- <div class="mb-2 pr-2 d-flex d-sm-block">
- <date-time-picker
- ref="dateTimePicker"
- class="flex-grow-1 show-last-dropdown"
- :value="selectedTimeRange"
- :options="$options.timeRanges"
- :utc="displayUtc"
- @input="onDateTimePickerInput"
- @invalid="onDateTimePickerInvalid"
- />
- </div>
-
- <div class="mb-2 pr-2 d-flex d-sm-block">
- <refresh-button />
- </div>
-
- <div class="flex-grow-1"></div>
-
- <div class="d-sm-flex">
- <div v-if="showRearrangePanelsBtn" class="gl-mb-3 gl-mr-3 gl-display-flex">
- <gl-button
- :pressed="isRearrangingPanels"
- variant="default"
- class="flex-grow-1 js-rearrange-button"
- @click="toggleRearrangingPanels"
- >
- {{ __('Arrange charts') }}
- </gl-button>
- </div>
-
- <div
- v-if="externalDashboardUrl && externalDashboardUrl.length"
- class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block"
- >
- <gl-button
- class="flex-grow-1 js-external-dashboard-link"
- variant="confirm"
- category="primary"
- :href="externalDashboardUrl"
- target="_blank"
- rel="noopener noreferrer"
- >
- {{ __('View full dashboard') }} <gl-icon name="external-link" />
- </gl-button>
- </div>
-
- <div class="gl-mb-3 gl-mr-3 d-flex d-sm-block">
- <actions-menu
- :adding-metrics-available="addingMetricsAvailable"
- :custom-metrics-path="customMetricsPath"
- :validate-query-path="validateQueryPath"
- :default-branch="defaultBranch"
- :is-ootb-dashboard="isOOTBDashboard"
- />
- </div>
-
- <template v-if="shouldShowSettingsButton">
- <span aria-hidden="true" class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"></span>
-
- <div class="gl-mb-3 gl-mr-3 d-flex d-sm-block">
- <gl-button
- v-gl-tooltip
- data-testid="metrics-settings-button"
- icon="settings"
- :href="operationsSettingsPath"
- :title="$options.i18n.metricsSettings"
- :aria-label="$options.i18n.metricsSettings"
- />
- </div>
- </template>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
deleted file mode 100644
index 9ad6da35d6b..00000000000
--- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue
+++ /dev/null
@@ -1,388 +0,0 @@
-<script>
-import {
- GlResizeObserverDirective,
- GlIcon,
- GlLink,
- GlLoadingIcon,
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
- GlModal,
- GlModalDirective,
- GlSprintf,
- GlTooltip,
- GlTooltipDirective,
-} from '@gitlab/ui';
-import { mapState } from 'vuex';
-import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import { isSafeURL } from '~/lib/utils/url_utility';
-import { __, n__ } from '~/locale';
-import TrackEventDirective from '~/vue_shared/directives/track_event';
-import { panelTypes } from '../constants';
-
-import { graphDataToCsv } from '../csv_export';
-import { downloadCSVOptions, generateLinkToChartOptions } from '../utils';
-import MonitorAnomalyChart from './charts/anomaly.vue';
-import MonitorBarChart from './charts/bar.vue';
-import MonitorColumnChart from './charts/column.vue';
-import MonitorEmptyChart from './charts/empty_chart.vue';
-import MonitorGaugeChart from './charts/gauge.vue';
-import MonitorHeatmapChart from './charts/heatmap.vue';
-import MonitorSingleStatChart from './charts/single_stat.vue';
-import MonitorStackedColumnChart from './charts/stacked_column.vue';
-import MonitorTimeSeriesChart from './charts/time_series.vue';
-
-const events = {
- timeRangeZoom: 'timerangezoom',
- expand: 'expand',
-};
-
-export default {
- components: {
- MonitorEmptyChart,
- GlIcon,
- GlLink,
- GlLoadingIcon,
- GlTooltip,
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
- GlModal,
- GlSprintf,
- },
- directives: {
- GlResizeObserver: GlResizeObserverDirective,
- GlModal: GlModalDirective,
- GlTooltip: GlTooltipDirective,
- TrackEvent: TrackEventDirective,
- },
- props: {
- clipboardText: {
- type: String,
- required: false,
- default: '',
- },
- graphData: {
- type: Object,
- required: false,
- default: null,
- },
- groupId: {
- type: String,
- required: false,
- default: 'dashboard-panel',
- },
- namespace: {
- type: String,
- required: false,
- default: 'monitoringDashboard',
- },
- settingsPath: {
- type: String,
- required: false,
- default: null,
- },
- },
- data() {
- return {
- showTitleTooltip: false,
- zoomedTimeRange: null,
- expandBtnAvailable: Boolean(this.$listeners[events.expand]),
- };
- },
- computed: {
- // Use functions to support dynamic namespaces in mapXXX helpers. Pattern described
- // in https://github.com/vuejs/vuex/issues/863#issuecomment-329510765
- ...mapState({
- deploymentData(state) {
- return state[this.namespace].deploymentData;
- },
- annotations(state) {
- return state[this.namespace].annotations;
- },
- projectPath(state) {
- return state[this.namespace].projectPath;
- },
- timeRange(state) {
- return state[this.namespace].timeRange;
- },
- dashboardTimezone(state) {
- return state[this.namespace].dashboardTimezone;
- },
- metricsSavedToDb(state, getters) {
- return getters[`${this.namespace}/metricsSavedToDb`];
- },
- selectedDashboard(state, getters) {
- return getters[`${this.namespace}/selectedDashboard`];
- },
- }),
- fixedCurrentTimeRange() {
- // convertToFixedRange throws an error if the time range
- // is not properly set.
- try {
- return convertToFixedRange(this.timeRange);
- } catch {
- return {};
- }
- },
- title() {
- return this.graphData?.title || '';
- },
- graphDataHasResult() {
- const metrics = this.graphData?.metrics || [];
- return metrics.some(({ result }) => result?.length > 0);
- },
- graphDataIsLoading() {
- const metrics = this.graphData?.metrics || [];
- return metrics.some(({ loading }) => loading);
- },
- csvText() {
- if (this.graphData) {
- return graphDataToCsv(this.graphData);
- }
- return null;
- },
- downloadCsv() {
- const data = new Blob([this.csvText], { type: 'text/plain' });
- return window.URL.createObjectURL(data);
- },
-
- /**
- * A chart is "basic" if it doesn't support
- * the same features as the TimeSeries based components
- * such as "annotations".
- *
- * @returns Vue Component wrapping a basic visualization
- */
- basicChartComponent() {
- if (this.isPanelType(panelTypes.SINGLE_STAT)) {
- return MonitorSingleStatChart;
- }
- if (this.isPanelType(panelTypes.GAUGE_CHART)) {
- return MonitorGaugeChart;
- }
- if (this.isPanelType(panelTypes.HEATMAP)) {
- return MonitorHeatmapChart;
- }
- if (this.isPanelType(panelTypes.BAR)) {
- return MonitorBarChart;
- }
- if (this.isPanelType(panelTypes.COLUMN)) {
- return MonitorColumnChart;
- }
- if (this.isPanelType(panelTypes.STACKED_COLUMN)) {
- return MonitorStackedColumnChart;
- }
- if (this.isPanelType(panelTypes.ANOMALY_CHART)) {
- return MonitorAnomalyChart;
- }
- return null;
- },
-
- /**
- * In monitoring, Time Series charts typically support
- * a larger feature set like "annotations", "deployment
- * data" and "datazoom".
- *
- * This is intentional as Time Series are more frequently
- * used.
- *
- * @returns Vue Component wrapping a time series visualization,
- * Area Charts are rendered by default.
- */
- timeSeriesChartComponent() {
- if (this.isPanelType(panelTypes.ANOMALY_CHART)) {
- return MonitorAnomalyChart;
- }
- return MonitorTimeSeriesChart;
- },
- isContextualMenuShown() {
- if (!this.graphDataHasResult) {
- return false;
- }
- // Only a few charts have a contextual menu, support
- // for more chart types planned at:
- // https://gitlab.com/groups/gitlab-org/-/epics/3573
- return (
- this.isPanelType(panelTypes.AREA_CHART) ||
- this.isPanelType(panelTypes.LINE_CHART) ||
- this.isPanelType(panelTypes.SINGLE_STAT) ||
- this.isPanelType(panelTypes.GAUGE_CHART)
- );
- },
- editCustomMetricLink() {
- if (this.graphData.metrics.length > 1) {
- return this.settingsPath;
- }
- return this.graphData?.metrics[0].edit_path;
- },
- editCustomMetricLinkText() {
- return n__('Metrics|Edit metric', 'Metrics|Edit metrics', this.graphData.metrics.length);
- },
- hasMetricsInDb() {
- const { metrics = [] } = this.graphData;
- return metrics.some(({ metricId }) => this.metricsSavedToDb.includes(metricId));
- },
- },
- mounted() {
- this.refreshTitleTooltip();
- },
- methods: {
- isPanelType(type) {
- return this.graphData?.type === type;
- },
- showToast() {
- this.$toast.show(__('Link copied'));
- },
- refreshTitleTooltip() {
- const { graphTitle } = this.$refs;
- this.showTitleTooltip =
- Boolean(graphTitle) && graphTitle.scrollWidth > graphTitle.offsetWidth;
- },
-
- downloadCSVOptions,
- generateLinkToChartOptions,
-
- onResize() {
- this.refreshTitleTooltip();
- },
- onDatazoom({ start, end }) {
- this.zoomedTimeRange = { start, end };
- this.$emit(events.timeRangeZoom, { start, end });
- },
- onExpand() {
- this.$emit(events.expand);
- },
- onExpandFromKeyboardShortcut() {
- if (this.isContextualMenuShown) {
- this.onExpand();
- }
- },
- safeUrl(url) {
- return isSafeURL(url) ? url : '#';
- },
- downloadCsvFromKeyboardShortcut() {
- if (this.csvText && this.isContextualMenuShown) {
- this.$refs.downloadCsvLink.$el.firstChild.click();
- }
- },
- copyChartLinkFromKeyboardShotcut() {
- if (this.clipboardText && this.isContextualMenuShown) {
- this.$refs.copyChartLink.$el.firstChild.click();
- }
- },
- },
- panelTypes,
-};
-</script>
-<template>
- <div v-gl-resize-observer="onResize" class="prometheus-graph">
- <div class="d-flex align-items-center">
- <slot name="top-left"></slot>
- <h5
- ref="graphTitle"
- class="prometheus-graph-title gl-font-lg font-weight-bold text-truncate gl-mr-3"
- >
- {{ title }}
- </h5>
- <gl-tooltip :target="() => $refs.graphTitle" :disabled="!showTitleTooltip">
- {{ title }}
- </gl-tooltip>
- <div class="flex-grow-1"></div>
- <div v-if="graphDataIsLoading" class="mx-1 mt-1">
- <gl-loading-icon size="sm" />
- </div>
- <div v-if="isContextualMenuShown" ref="contextualMenu">
- <div data-testid="dropdown-wrapper" class="d-flex align-items-center">
- <!--
- This component should be replaced with a variant developed
- as part of https://gitlab.com/gitlab-org/gitlab-ui/-/issues/936
- The variant will create a dropdown with an icon, no text and no caret
- -->
- <gl-dropdown
- v-gl-tooltip
- icon="ellipsis_v"
- :text="__('More actions')"
- :text-sr-only="true"
- toggle-class="gl-px-3!"
- no-caret
- right
- :title="__('More actions')"
- >
- <gl-dropdown-item v-if="expandBtnAvailable" ref="expandBtn" @click.prevent="onExpand">
- {{ s__('Metrics|Expand panel') }}
- </gl-dropdown-item>
- <gl-dropdown-item
- v-if="editCustomMetricLink"
- ref="editMetricLink"
- :href="editCustomMetricLink"
- >
- {{ editCustomMetricLinkText }}
- </gl-dropdown-item>
-
- <gl-dropdown-item
- v-if="csvText"
- ref="downloadCsvLink"
- v-track-event="downloadCSVOptions(title)"
- :href="downloadCsv"
- download="chart_metrics.csv"
- >
- {{ __('Download CSV') }}
- </gl-dropdown-item>
- <gl-dropdown-item
- v-if="clipboardText"
- ref="copyChartLink"
- v-track-event="generateLinkToChartOptions(clipboardText)"
- :data-clipboard-text="clipboardText"
- @click="showToast(clipboardText)"
- >
- {{ __('Copy link to chart') }}
- </gl-dropdown-item>
-
- <template v-if="graphData.links && graphData.links.length">
- <gl-dropdown-divider />
- <gl-dropdown-item
- v-for="(link, index) in graphData.links"
- :key="index"
- :href="safeUrl(link.url)"
- class="text-break"
- >{{ link.title }}</gl-dropdown-item
- >
- </template>
- <template v-if="selectedDashboard && selectedDashboard.can_edit">
- <gl-dropdown-divider />
- <gl-dropdown-item ref="manageLinksItem" :href="selectedDashboard.project_blob_path">{{
- s__('Metrics|Manage chart links')
- }}</gl-dropdown-item>
- </template>
- </gl-dropdown>
- </div>
- </div>
- </div>
-
- <monitor-empty-chart v-if="!graphDataHasResult" />
- <component
- :is="basicChartComponent"
- v-else-if="basicChartComponent"
- :graph-data="graphData"
- :timezone="dashboardTimezone"
- v-bind="$attrs"
- v-on="$listeners"
- />
- <component
- :is="timeSeriesChartComponent"
- v-else
- ref="timeSeriesChart"
- :graph-data="graphData"
- :deployment-data="deploymentData"
- :annotations="annotations"
- :project-path="projectPath"
- :group-id="groupId"
- :timezone="dashboardTimezone"
- :time-range="fixedCurrentTimeRange"
- v-bind="$attrs"
- v-on="$listeners"
- @datazoom="onDatazoom"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue
deleted file mode 100644
index e8a9c24f5c2..00000000000
--- a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue
+++ /dev/null
@@ -1,204 +0,0 @@
-<script>
-import {
- GlCard,
- GlForm,
- GlFormGroup,
- GlFormTextarea,
- GlButton,
- GlSprintf,
- GlAlert,
- GlTooltipDirective,
-} from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
-import { s__ } from '~/locale';
-import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
-import { timeRanges } from '~/vue_shared/constants';
-import DashboardPanel from './dashboard_panel.vue';
-
-const initialYml = `title: Go heap size
-type: area-chart
-y_axis:
- format: 'bytes'
-metrics:
- - metric_id: 'go_memstats_alloc_bytes_1'
- query_range: 'go_memstats_alloc_bytes'
-`;
-
-export default {
- i18n: {
- refreshButtonLabel: s__('Metrics|Refresh Prometheus data'),
- },
- components: {
- GlCard,
- GlForm,
- GlFormGroup,
- GlFormTextarea,
- GlButton,
- GlSprintf,
- GlAlert,
- DashboardPanel,
- DateTimePicker,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- data() {
- return {
- yml: initialYml,
- };
- },
- computed: {
- ...mapState('monitoringDashboard', [
- 'panelPreviewIsLoading',
- 'panelPreviewError',
- 'panelPreviewGraphData',
- 'panelPreviewTimeRange',
- 'panelPreviewIsShown',
- 'projectPath',
- 'addDashboardDocumentationPath',
- ]),
- },
- methods: {
- ...mapActions('monitoringDashboard', [
- 'fetchPanelPreview',
- 'fetchPanelPreviewMetrics',
- 'setPanelPreviewTimeRange',
- ]),
- onSubmit() {
- this.fetchPanelPreview(this.yml);
- },
- onDateTimePickerInput(timeRange) {
- this.setPanelPreviewTimeRange(timeRange);
- // refetch data only if preview has been clicked
- // and there are no errors
- if (this.panelPreviewIsShown && !this.panelPreviewError) {
- this.fetchPanelPreviewMetrics();
- }
- },
- onRefresh() {
- // refetch data only if preview has been clicked
- // and there are no errors
- if (this.panelPreviewIsShown && !this.panelPreviewError) {
- this.fetchPanelPreviewMetrics();
- }
- },
- },
- timeRanges,
-};
-</script>
-<template>
- <div class="prometheus-panel-builder">
- <div class="gl-xs-flex-direction-column gl-display-flex gl-mx-n3">
- <gl-card class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3 gl-mb-5">
- <template #header>
- <h2 class="gl-font-size-h2 gl-my-3">{{ s__('Metrics|1. Define and preview panel') }}</h2>
- </template>
- <template #default>
- <p>{{ s__('Metrics|Define panel YAML below to preview panel.') }}</p>
- <gl-form @submit.prevent="onSubmit">
- <gl-form-group :label="s__('Metrics|Panel YAML')" label-for="panel-yml-input">
- <gl-form-textarea
- id="panel-yml-input"
- v-model="yml"
- class="gl-h-200! gl-font-monospace!"
- />
- </gl-form-group>
- <div class="gl-text-right">
- <gl-button
- ref="clipboardCopyBtn"
- variant="confirm"
- category="secondary"
- :data-clipboard-text="yml"
- class="gl-xs-w-full gl-xs-mb-3"
- @click="$toast.show(s__('Metrics|Panel YAML copied'))"
- >
- {{ s__('Metrics|Copy YAML') }}
- </gl-button>
- <gl-button
- type="submit"
- variant="confirm"
- :disabled="panelPreviewIsLoading"
- class="js-no-auto-disable gl-xs-w-full"
- >
- {{ s__('Metrics|Preview panel') }}
- </gl-button>
- </div>
- </gl-form>
- </template>
- </gl-card>
-
- <gl-card
- class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3 gl-mb-5"
- body-class="gl-display-flex gl-flex-direction-column"
- >
- <template #header>
- <h2 class="gl-font-size-h2 gl-my-3">
- {{ s__('Metrics|2. Paste panel YAML into dashboard') }}
- </h2>
- </template>
- <template #default>
- <div
- class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-justify-content-center"
- >
- <p>
- {{ s__('Metrics|Copy and paste the panel YAML into your dashboard YAML file.') }}
- <br />
- <gl-sprintf
- :message="
- s__(
- 'Metrics|Dashboard files can be found in %{codeStart}.gitlab/dashboards%{codeEnd} at the root of this project.',
- )
- "
- >
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </p>
- </div>
-
- <div class="gl-text-right">
- <gl-button
- ref="viewDocumentationBtn"
- category="secondary"
- class="gl-xs-w-full gl-xs-mb-3"
- variant="confirm"
- target="_blank"
- :href="addDashboardDocumentationPath"
- >
- {{ s__('Metrics|View documentation') }}
- </gl-button>
- <gl-button
- ref="openRepositoryBtn"
- variant="confirm"
- :href="projectPath"
- class="gl-xs-w-full"
- >
- {{ s__('Metrics|Open repository') }}
- </gl-button>
- </div>
- </template>
- </gl-card>
- </div>
-
- <gl-alert v-if="panelPreviewError" variant="warning" :dismissible="false">
- {{ panelPreviewError }}
- </gl-alert>
- <date-time-picker
- ref="dateTimePicker"
- class="gl-flex-grow-1 preview-date-time-picker gl-xs-mb-3"
- :value="panelPreviewTimeRange"
- :options="$options.timeRanges"
- @input="onDateTimePickerInput"
- />
- <gl-button
- v-gl-tooltip
- data-testid="previewRefreshButton"
- icon="retry"
- :title="$options.i18n.refreshButtonLabel"
- :aria-label="$options.i18n.refreshButtonLabel"
- @click="onRefresh"
- />
- <dashboard-panel :graph-data="panelPreviewGraphData" />
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
deleted file mode 100644
index 7fae684315c..00000000000
--- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
+++ /dev/null
@@ -1,127 +0,0 @@
-<script>
-import {
- GlIcon,
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlDropdownDivider,
- GlSearchBoxByType,
- GlModalDirective,
-} from '@gitlab/ui';
-import { mapState, mapGetters } from 'vuex';
-
-const events = {
- selectDashboard: 'selectDashboard',
-};
-
-export default {
- components: {
- GlIcon,
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlDropdownDivider,
- GlSearchBoxByType,
- },
- directives: {
- GlModal: GlModalDirective,
- },
- props: {
- defaultBranch: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- searchTerm: '',
- };
- },
- computed: {
- ...mapState('monitoringDashboard', ['allDashboards']),
- ...mapGetters('monitoringDashboard', ['selectedDashboard']),
- selectedDashboardText() {
- return this.selectedDashboard?.display_name;
- },
- selectedDashboardPath() {
- return this.selectedDashboard?.path;
- },
-
- filteredDashboards() {
- return this.allDashboards.filter(({ display_name: displayName = '' }) =>
- displayName.toLowerCase().includes(this.searchTerm.toLowerCase()),
- );
- },
- shouldShowNoMsgContainer() {
- return this.filteredDashboards.length === 0;
- },
- starredDashboards() {
- return this.filteredDashboards.filter(({ starred }) => starred);
- },
- nonStarredDashboards() {
- return this.filteredDashboards.filter(({ starred }) => !starred);
- },
- },
- methods: {
- dashboardDisplayName(dashboard) {
- return dashboard.display_name || dashboard.path || '';
- },
- selectDashboard(dashboard) {
- this.$emit(events.selectDashboard, dashboard);
- },
- },
-};
-</script>
-<template>
- <gl-dropdown
- toggle-class="dropdown-menu-toggle"
- menu-class="monitor-dashboard-dropdown-menu"
- :text="selectedDashboardText"
- >
- <div class="d-flex flex-column overflow-hidden">
- <gl-dropdown-section-header>{{ __('Dashboard') }}</gl-dropdown-section-header>
- <gl-search-box-by-type ref="monitorDashboardsDropdownSearch" v-model="searchTerm" />
-
- <div class="flex-fill overflow-auto">
- <gl-dropdown-item
- v-for="dashboard in starredDashboards"
- :key="dashboard.path"
- is-check-item
- :is-checked="dashboard.path === selectedDashboardPath"
- @click="selectDashboard(dashboard)"
- >
- <div class="gl-display-flex">
- <span class="gl-flex-grow-1 gl-min-w-0 gl-overflow-hidden gl-overflow-wrap-break">
- {{ dashboardDisplayName(dashboard) }}
- </span>
- <gl-icon class="text-muted gl-flex-shrink-0 gl-ml-3 gl-align-self-center" name="star" />
- </div>
- </gl-dropdown-item>
- <gl-dropdown-divider
- v-if="starredDashboards.length && nonStarredDashboards.length"
- ref="starredListDivider"
- />
-
- <gl-dropdown-item
- v-for="dashboard in nonStarredDashboards"
- :key="dashboard.path"
- is-check-item
- :is-checked="dashboard.path === selectedDashboardPath"
- @click="selectDashboard(dashboard)"
- >
- <span class="gl-overflow-hidden gl-overflow-wrap-break">
- {{ dashboardDisplayName(dashboard) }}
- </span>
- </gl-dropdown-item>
- </div>
-
- <div
- v-show="shouldShowNoMsgContainer"
- ref="monitorDashboardsDropdownMsg"
- class="text-secondary no-matches-message"
- >
- {{ __('No matching results') }}
- </div>
- </div>
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue
deleted file mode 100644
index 9ad14b3d52e..00000000000
--- a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue
+++ /dev/null
@@ -1,138 +0,0 @@
-<script>
-import { GlFormGroup, GlFormInput, GlFormRadioGroup, GlFormTextarea } from '@gitlab/ui';
-import { escape as esc } from 'lodash';
-import { __, s__, sprintf } from '~/locale';
-
-const defaultFileName = (dashboard) => dashboard.path.split('/').reverse()[0];
-
-export default {
- components: {
- GlFormGroup,
- GlFormInput,
- GlFormRadioGroup,
- GlFormTextarea,
- },
- props: {
- dashboard: {
- type: Object,
- required: true,
- },
- defaultBranch: {
- type: String,
- required: true,
- },
- },
- radioVals: {
- /* Use the default branch (e.g. main) */
- DEFAULT: 'DEFAULT',
- /* Create a new branch */
- NEW: 'NEW',
- },
- data() {
- return {
- form: {
- dashboard: this.dashboard.path,
- fileName: defaultFileName(this.dashboard),
- commitMessage: '',
- },
- branchName: '',
- branchOption: this.$options.radioVals.NEW,
- branchOptions: [
- {
- value: this.$options.radioVals.DEFAULT,
- html: sprintf(
- __('Commit to %{branchName} branch'),
- {
- branchName: `<strong>${esc(this.defaultBranch)}</strong>`,
- },
- false,
- ),
- },
- { value: this.$options.radioVals.NEW, text: __('Create new branch') },
- ],
- };
- },
- computed: {
- defaultCommitMsg() {
- return sprintf(s__('Metrics|Create custom dashboard %{fileName}'), {
- fileName: this.form.fileName,
- });
- },
- fileNameState() {
- // valid if empty or *.yml
- return !(this.form.fileName && !this.form.fileName.endsWith('.yml'));
- },
- fileNameFeedback() {
- return !this.fileNameState ? __('The file name should have a .yml extension') : '';
- },
- },
- mounted() {
- this.change();
- },
- methods: {
- change() {
- this.$emit('change', {
- ...this.form,
- commitMessage: this.form.commitMessage || this.defaultCommitMsg,
- branch:
- this.branchOption === this.$options.radioVals.NEW ? this.branchName : this.defaultBranch,
- });
- },
- focus(option) {
- if (option === this.$options.radioVals.NEW) {
- this.$nextTick(() => {
- this.$refs.branchName.$el.focus();
- });
- }
- },
- },
-};
-</script>
-<template>
- <form @change="change">
- <p class="text-muted">
- {{
- s__(`Metrics|You can save a copy of this dashboard to your repository
- so it can be customized. Select a file name and branch to save it.`)
- }}
- </p>
- <gl-form-group
- ref="fileNameFormGroup"
- :label="__('File name')"
- :state="fileNameState"
- :invalid-feedback="fileNameFeedback"
- label-size="sm"
- label-for="fileName"
- >
- <gl-form-input id="fileName" ref="fileName" v-model="form.fileName" :required="true" />
- </gl-form-group>
- <gl-form-group :label="__('Branch')" label-size="sm" label-for="branch">
- <gl-form-radio-group
- ref="branchOption"
- v-model="branchOption"
- :checked="$options.radioVals.NEW"
- :stacked="true"
- :options="branchOptions"
- @change="focus"
- />
- <gl-form-input
- v-show="branchOption === $options.radioVals.NEW"
- id="branchName"
- ref="branchName"
- v-model="branchName"
- />
- </gl-form-group>
- <gl-form-group
- :label="__('Commit message (optional)')"
- label-size="sm"
- label-for="commitMessage"
- >
- <gl-form-textarea
- id="commitMessage"
- ref="commitMessage"
- v-model="form.commitMessage"
- :placeholder="defaultCommitMsg"
- />
- </gl-form-group>
- </form>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue
deleted file mode 100644
index d1ce7bad39a..00000000000
--- a/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue
+++ /dev/null
@@ -1,106 +0,0 @@
-<script>
-import { GlAlert, GlModal } from '@gitlab/ui';
-import { mapActions, mapGetters } from 'vuex';
-import { __, s__ } from '~/locale';
-import DuplicateDashboardForm from './duplicate_dashboard_form.vue';
-
-const events = {
- dashboardDuplicated: 'dashboardDuplicated',
-};
-
-export default {
- components: { GlAlert, GlModal, DuplicateDashboardForm },
- props: {
- defaultBranch: {
- type: String,
- required: true,
- },
- modalId: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- alert: null,
- loading: false,
- form: {},
- };
- },
- computed: {
- ...mapGetters('monitoringDashboard', ['selectedDashboard']),
- okButtonText() {
- return this.loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate');
- },
- actionPrimaryProps() {
- return {
- text: this.okButtonText,
- attributes: {
- loading: this.loading,
- variant: 'confirm',
- },
- };
- },
- actionCancelProps() {
- return {
- text: __('Cancel'),
- };
- },
- },
- methods: {
- ...mapActions('monitoringDashboard', ['duplicateSystemDashboard']),
- ok(bvModalEvt) {
- // Prevent modal from hiding in case submit fails
- bvModalEvt.preventDefault();
-
- this.loading = true;
- this.alert = null;
- this.duplicateSystemDashboard(this.form)
- .then((createdDashboard) => {
- this.loading = false;
- this.alert = null;
-
- // Trigger hide modal as submit is successful
- this.$refs.duplicateDashboardModal.hide();
-
- // Dashboards in the default branch become available immediately.
- // Not so in other branches, so we refresh the current dashboard
- const dashboard =
- this.form.branch === this.defaultBranch ? createdDashboard : this.selectedDashboard;
- this.$emit(events.dashboardDuplicated, dashboard);
- })
- .catch((error) => {
- this.loading = false;
- this.alert = error;
- });
- },
- hide() {
- this.alert = null;
- },
- formChange(form) {
- this.form = form;
- },
- },
-};
-</script>
-
-<template>
- <gl-modal
- ref="duplicateDashboardModal"
- :modal-id="modalId"
- :title="s__('Metrics|Duplicate dashboard')"
- :action-primary="actionPrimaryProps"
- :action-cancel="actionCancelProps"
- @ok="ok"
- @hide="hide"
- >
- <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null">
- {{ alert }}
- </gl-alert>
- <duplicate-dashboard-form
- :dashboard="selectedDashboard"
- :default-branch="defaultBranch"
- @change="formChange"
- />
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/embeds/embed_group.vue b/app/assets/javascripts/monitoring/components/embeds/embed_group.vue
deleted file mode 100644
index 8eef3d69a4f..00000000000
--- a/app/assets/javascripts/monitoring/components/embeds/embed_group.vue
+++ /dev/null
@@ -1,102 +0,0 @@
-<script>
-import { GlButton, GlCard, GlIcon } from '@gitlab/ui';
-import sum from 'lodash/sum';
-import { mapState, mapActions, mapGetters } from 'vuex';
-import { n__ } from '~/locale';
-import { monitoringDashboard } from '~/monitoring/stores';
-import MetricEmbed from './metric_embed.vue';
-
-export default {
- components: {
- GlButton,
- GlCard,
- GlIcon,
- MetricEmbed,
- },
- props: {
- urls: {
- type: Array,
- required: true,
- validator: (urls) => urls.length > 0,
- },
- },
- data() {
- return {
- isCollapsed: false,
- };
- },
- computed: {
- ...mapState('embedGroup', ['module']),
- ...mapGetters('embedGroup', ['metricsWithData']),
- arrowIconName() {
- return this.isCollapsed ? 'chevron-right' : 'chevron-down';
- },
- bodyClass() {
- return ['border-top', 'pl-3', 'pt-3', { 'd-none': this.isCollapsed }];
- },
- buttonLabel() {
- return this.isCollapsed
- ? n__('View chart', 'View charts', this.numCharts)
- : n__('Hide chart', 'Hide charts', this.numCharts);
- },
- containerClass() {
- return this.isSingleChart ? 'col-lg-12' : 'col-lg-6';
- },
- numCharts() {
- if (this.metricsWithData === null) {
- return 0;
- }
- return sum(this.metricsWithData);
- },
- isSingleChart() {
- return this.numCharts === 1;
- },
- },
- created() {
- this.urls.forEach((url, index) => {
- const name = this.getNamespace(index);
- this.$store.registerModule(name, monitoringDashboard);
- this.addModule(name);
- });
- },
- methods: {
- ...mapActions('embedGroup', ['addModule']),
- getNamespace(id) {
- return `monitoringDashboard/${id}`;
- },
- toggleCollapsed() {
- this.isCollapsed = !this.isCollapsed;
- },
- },
-};
-</script>
-<template>
- <gl-card
- v-show="numCharts > 0"
- class="collapsible-card border p-0 gl-mb-5"
- header-class="d-flex align-items-center border-bottom-0 py-2"
- :body-class="bodyClass"
- >
- <template #header>
- <gl-button
- class="collapsible-card-btn gl-display-flex gl-text-decoration-none gl-reset-color! gl-hover-text-blue-800! gl-shadow-none!"
- :aria-label="buttonLabel"
- variant="link"
- category="tertiary"
- @click="toggleCollapsed"
- >
- <gl-icon class="mr-1" :name="arrowIconName" />
- {{ buttonLabel }}
- </gl-button>
- </template>
- <div class="d-flex flex-wrap">
- <metric-embed
- v-for="(url, index) in urls"
- :key="`${index}/${url}`"
- :dashboard-url="url"
- :namespace="getNamespace(index)"
- :container-class="containerClass"
- />
- </div>
- </gl-card>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue b/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue
deleted file mode 100644
index 25500747573..00000000000
--- a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue
+++ /dev/null
@@ -1,125 +0,0 @@
-<script>
-import { mapState, mapActions } from 'vuex';
-import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import DashboardPanel from '~/monitoring/components/dashboard_panel.vue';
-import { defaultTimeRange } from '~/vue_shared/constants';
-import { sidebarAnimationDuration } from '../../constants';
-import { timeRangeFromUrl, removeTimeRangeParams } from '../../utils';
-
-let sidebarMutationObserver;
-
-export default {
- components: {
- DashboardPanel,
- },
- props: {
- containerClass: {
- type: String,
- required: false,
- default: 'col-lg-12',
- },
- dashboardUrl: {
- type: String,
- required: true,
- },
- namespace: {
- type: String,
- required: false,
- default: 'monitoringDashboard',
- },
- },
- data() {
- const timeRange = timeRangeFromUrl(this.dashboardUrl) || defaultTimeRange;
- return {
- timeRange: convertToFixedRange(timeRange),
- elWidth: 0,
- };
- },
- computed: {
- ...mapState({
- dashboard(state) {
- return state[this.namespace].dashboard;
- },
- metricsWithData(state, getters) {
- return getters[`${this.namespace}/metricsWithData`]();
- },
- }),
- charts() {
- if (!this.dashboard || !this.dashboard.panelGroups) {
- return [];
- }
- return this.dashboard.panelGroups.reduce(
- (acc, currentGroup) => acc.concat(currentGroup.panels.filter(this.chartHasData)),
- [],
- );
- },
- isSingleChart() {
- return this.charts.length === 1;
- },
- embedClass() {
- return this.isSingleChart ? this.containerClass : 'col-lg-12';
- },
- panelClass() {
- return this.isSingleChart ? 'col-lg-12' : 'col-lg-6';
- },
- },
- mounted() {
- this.setInitialState({
- dashboardEndpoint: removeTimeRangeParams(this.dashboardUrl),
- });
- this.setShowErrorBanner(false);
- this.setTimeRange(this.timeRange);
- this.fetchDashboard();
-
- sidebarMutationObserver = new MutationObserver(this.onSidebarMutation);
- sidebarMutationObserver.observe(document.querySelector('.layout-page'), {
- attributes: true,
- childList: false,
- subtree: false,
- });
- },
- beforeDestroy() {
- if (sidebarMutationObserver) {
- sidebarMutationObserver.disconnect();
- }
- },
- methods: {
- // Use function args to support dynamic namespaces in mapXXX helpers. Pattern described
- // in https://github.com/vuejs/vuex/issues/863#issuecomment-329510765
- ...mapActions({
- setTimeRange(dispatch, payload) {
- return dispatch(`${this.namespace}/setTimeRange`, payload);
- },
- fetchDashboard(dispatch, payload) {
- return dispatch(`${this.namespace}/fetchDashboard`, payload);
- },
- setInitialState(dispatch, payload) {
- return dispatch(`${this.namespace}/setInitialState`, payload);
- },
- setShowErrorBanner(dispatch, payload) {
- return dispatch(`${this.namespace}/setShowErrorBanner`, payload);
- },
- }),
- chartHasData(chart) {
- return chart.metrics.some((metric) => this.metricsWithData.includes(metric.metricId));
- },
- onSidebarMutation() {
- setTimeout(() => {
- this.elWidth = this.$el.clientWidth;
- }, sidebarAnimationDuration);
- },
- },
-};
-</script>
-<template>
- <div class="metrics-embed p-0 d-flex flex-wrap" :class="embedClass">
- <dashboard-panel
- v-for="(graphData, graphIndex) in charts"
- :key="`dashboard-panel-${graphIndex}`"
- :class="panelClass"
- :graph-data="graphData"
- :group-id="dashboardUrl"
- :namespace="namespace"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue
deleted file mode 100644
index 867f7139d71..00000000000
--- a/app/assets/javascripts/monitoring/components/empty_state.vue
+++ /dev/null
@@ -1,114 +0,0 @@
-<script>
-import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
-import { __ } from '~/locale';
-import { dashboardEmptyStates } from '../constants';
-
-export default {
- components: {
- GlLoadingIcon,
- GlEmptyState,
- },
- props: {
- selectedState: {
- type: String,
- required: true,
- validator: (state) => Object.values(dashboardEmptyStates).includes(state),
- },
- documentationPath: {
- type: String,
- required: true,
- },
- settingsPath: {
- type: String,
- required: false,
- default: '',
- },
- clustersPath: {
- type: String,
- required: false,
- default: '',
- },
- emptyGettingStartedSvgPath: {
- type: String,
- required: true,
- },
- emptyLoadingSvgPath: {
- type: String,
- required: true,
- },
- emptyNoDataSvgPath: {
- type: String,
- required: true,
- },
- emptyNoDataSmallSvgPath: {
- type: String,
- required: true,
- },
- emptyUnableToConnectSvgPath: {
- type: String,
- required: true,
- },
- compact: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- /**
- * Possible empty states.
- * Keys in each state must match GlEmptyState props
- */
- states: {
- [dashboardEmptyStates.GETTING_STARTED]: {
- svgPath: this.emptyGettingStartedSvgPath,
- title: __('Get started with performance monitoring'),
- description: __(`Stay updated about the performance and health
- of your environment by configuring Prometheus to monitor your deployments.`),
- primaryButtonText: __('Install on clusters'),
- primaryButtonLink: this.clustersPath,
- secondaryButtonText: __('Configure existing installation'),
- secondaryButtonLink: this.settingsPath,
- },
- [dashboardEmptyStates.NO_DATA]: {
- svgPath: this.emptyNoDataSvgPath,
- title: __('No data found'),
- description: __(`You are connected to the Prometheus server, but there is currently
- no data to display.`),
- primaryButtonText: __('Configure Prometheus'),
- primaryButtonLink: this.settingsPath,
- secondaryButtonText: '',
- secondaryButtonLink: '',
- },
- [dashboardEmptyStates.UNABLE_TO_CONNECT]: {
- svgPath: this.emptyUnableToConnectSvgPath,
- title: __('Unable to connect to Prometheus server'),
- description: __(
- 'Ensure connectivity is available from the GitLab server to the Prometheus server',
- ),
- primaryButtonText: __('View documentation'),
- primaryButtonLink: this.documentationPath,
- secondaryButtonText: __('Configure Prometheus'),
- secondaryButtonLink: this.settingsPath,
- },
- },
- };
- },
- computed: {
- isLoading() {
- return this.selectedState === dashboardEmptyStates.LOADING;
- },
- currentState() {
- return this.states[this.selectedState];
- },
- },
-};
-</script>
-
-<template>
- <div>
- <gl-loading-icon v-if="isLoading" size="xl" class="gl-my-9" />
- <gl-empty-state v-if="currentState" v-bind="currentState" :compact="compact" />
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue
deleted file mode 100644
index 74a806c50a9..00000000000
--- a/app/assets/javascripts/monitoring/components/graph_group.vue
+++ /dev/null
@@ -1,87 +0,0 @@
-<script>
-import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
-
-export default {
- components: {
- GlLoadingIcon,
- GlIcon,
- },
- props: {
- name: {
- type: String,
- required: true,
- },
- showPanels: {
- type: Boolean,
- required: false,
- default: true,
- },
- isLoading: {
- type: Boolean,
- required: false,
- default: false,
- },
- /**
- * Initial value of collapse on mount.
- */
- collapseGroup: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- isCollapsed: this.collapseGroup,
- };
- },
- computed: {
- caretIcon() {
- return this.isCollapsed ? 'chevron-lg-right' : 'chevron-lg-down';
- },
- },
- watch: {
- collapseGroup(val) {
- // Respond to changes in collapseGroup but do not
- // collapse it once was opened by the user.
- if (this.showPanels && !val) {
- this.isCollapsed = false;
- }
- },
- },
- methods: {
- collapse() {
- this.isCollapsed = !this.isCollapsed;
- },
- },
-};
-</script>
-
-<template>
- <div v-if="showPanels" ref="graph-group" class="card prometheus-panel">
- <div class="card-header d-flex align-items-center">
- <h4 class="flex-grow-1">{{ name }}</h4>
- <gl-loading-icon v-if="isLoading" size="sm" name="loading" />
- <a
- data-testid="group-toggle-button"
- :aria-label="__('Toggle collapse')"
- :icon="caretIcon"
- role="button"
- class="js-graph-group-toggle gl-display-flex gl-ml-2 gl-text-gray-900"
- tabindex="0"
- @click="collapse"
- @keyup.enter="collapse"
- >
- <gl-icon :name="caretIcon" />
- </a>
- </div>
- <div
- v-show="!isCollapsed"
- ref="graph-group-content"
- class="card-body prometheus-graph-group p-0"
- >
- <slot></slot>
- </div>
- </div>
- <div v-else ref="graph-group-content" class="prometheus-graph-group"><slot></slot></div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/group_empty_state.vue b/app/assets/javascripts/monitoring/components/group_empty_state.vue
deleted file mode 100644
index a67770b93be..00000000000
--- a/app/assets/javascripts/monitoring/components/group_empty_state.vue
+++ /dev/null
@@ -1,109 +0,0 @@
-<script>
-import { GlEmptyState } from '@gitlab/ui';
-import SafeHtml from '~/vue_shared/directives/safe_html';
-import { __, sprintf } from '~/locale';
-import { metricStates } from '../constants';
-
-export default {
- components: {
- GlEmptyState,
- },
- directives: {
- SafeHtml,
- },
- props: {
- documentationPath: {
- type: String,
- required: true,
- },
- settingsPath: {
- type: String,
- required: true,
- },
- selectedState: {
- type: String,
- required: true,
- },
- svgPath: {
- type: String,
- required: true,
- },
- },
- data() {
- const documentationLink = `<a href="${this.documentationPath}">${__('More information')}</a>`;
- return {
- states: {
- [metricStates.NO_DATA]: {
- title: __('No data to display'),
- slottedDescription: sprintf(
- __(
- 'The data source is connected, but there is no data to display. %{documentationLink}',
- ),
- { documentationLink },
- false,
- ),
- },
- [metricStates.TIMEOUT]: {
- title: __('Connection timed out'),
- slottedDescription: sprintf(
- __(
- "Charts can't be displayed as the request for data has timed out. %{documentationLink}",
- ),
- { documentationLink },
- false,
- ),
- },
- [metricStates.CONNECTION_FAILED]: {
- title: __('Connection failed'),
- description: __(`We couldn't reach the Prometheus server.
- Either the server no longer exists or the configuration details need updating.`),
- buttonText: __('Verify configuration'),
- buttonPath: this.settingsPath,
- },
- [metricStates.BAD_QUERY]: {
- title: __('Query cannot be processed'),
- slottedDescription: sprintf(
- __(
- `The Prometheus server responded with "bad request".
- Please check your queries are correct and are supported in your Prometheus version. %{documentationLink}`,
- ),
- { documentationLink },
- false,
- ),
- buttonText: __('Verify configuration'),
- buttonPath: this.settingsPath,
- },
- [metricStates.LOADING]: {
- title: __('Waiting for performance data'),
- description: __(`Creating graphs uses the data from the Prometheus server.
- If this takes a long time, ensure that data is available.`),
- },
- [metricStates.UNKNOWN_ERROR]: {
- title: __('An error has occurred'),
- description: __('An error occurred while loading the data. Please try again.'),
- },
- },
- };
- },
- computed: {
- currentState() {
- return this.states[this.selectedState] || this.states[metricStates.UNKNOWN_ERROR];
- },
- },
-};
-</script>
-
-<template>
- <gl-empty-state
- :title="currentState.title"
- :primary-button-text="currentState.buttonText"
- :primary-button-link="currentState.buttonPath"
- :description="currentState.description"
- :svg-path="svgPath"
- :compact="true"
- >
- <template v-if="currentState.slottedDescription" #description>
- <div v-safe-html="currentState.slottedDescription"></div>
- </template>
- </gl-empty-state>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/links_section.vue b/app/assets/javascripts/monitoring/components/links_section.vue
deleted file mode 100644
index fb5ab12916e..00000000000
--- a/app/assets/javascripts/monitoring/components/links_section.vue
+++ /dev/null
@@ -1,32 +0,0 @@
-<script>
-import { GlIcon, GlLink } from '@gitlab/ui';
-import { mapGetters } from 'vuex';
-
-export default {
- components: {
- GlIcon,
- GlLink,
- },
- computed: {
- ...mapGetters('monitoringDashboard', { links: 'linksWithMetadata' }),
- },
-};
-</script>
-<template>
- <div
- ref="linksSection"
- class="gl-sm-display-flex gl-sm-flex-wrap gl-mt-5 gl-p-3 gl-bg-gray-10 border gl-rounded-base links-section"
- >
- <div
- v-for="(link, key) in links"
- :key="key"
- class="gl-mb-1 gl-mr-5 gl-display-flex gl-sm-display-block gl-hover-text-blue-600-children gl-word-break-all"
- >
- <gl-link :href="link.url" class="gl-text-gray-900 gl-text-decoration-none!"
- ><gl-icon name="link" class="gl-text-gray-500 gl-vertical-align-text-bottom gl-mr-2" />{{
- link.title
- }}
- </gl-link>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/refresh_button.vue b/app/assets/javascripts/monitoring/components/refresh_button.vue
deleted file mode 100644
index 55c602db33d..00000000000
--- a/app/assets/javascripts/monitoring/components/refresh_button.vue
+++ /dev/null
@@ -1,168 +0,0 @@
-<script>
-import {
- GlButtonGroup,
- GlButton,
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
- GlTooltipDirective,
-} from '@gitlab/ui';
-import Visibility from 'visibilityjs';
-import { mapActions } from 'vuex';
-import { n__, __, s__ } from '~/locale';
-
-const makeInterval = (length = 0, unit = 's') => {
- const shortLabel = `${length}${unit}`;
- switch (unit) {
- case 'd':
- return {
- interval: length * 24 * 60 * 60 * 1000,
- shortLabel,
- label: n__('%d day', '%d days', length),
- };
- case 'h':
- return {
- interval: length * 60 * 60 * 1000,
- shortLabel,
- label: n__('%d hour', '%d hours', length),
- };
- case 'm':
- return {
- interval: length * 60 * 1000,
- shortLabel,
- label: n__('%d minute', '%d minutes', length),
- };
- case 's':
- default:
- return {
- interval: length * 1000,
- shortLabel,
- label: n__('%d second', '%d seconds', length),
- };
- }
-};
-
-export default {
- i18n: {
- refreshDashboard: s__('Metrics|Refresh dashboard'),
- },
- components: {
- GlButtonGroup,
- GlButton,
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- data() {
- return {
- refreshInterval: null,
- timeoutId: null,
- };
- },
- computed: {
- dropdownText() {
- return this.refreshInterval?.shortLabel ?? __('Off');
- },
- },
- watch: {
- refreshInterval() {
- if (this.refreshInterval !== null) {
- this.startAutoRefresh();
- } else {
- this.stopAutoRefresh();
- }
- },
- },
- destroyed() {
- this.stopAutoRefresh();
- },
- methods: {
- ...mapActions('monitoringDashboard', ['fetchDashboardData']),
-
- refresh() {
- this.fetchDashboardData();
- },
- startAutoRefresh() {
- const schedule = () => {
- if (this.refreshInterval) {
- this.timeoutId = setTimeout(this.startAutoRefresh, this.refreshInterval.interval);
- }
- };
-
- this.stopAutoRefresh();
-
- if (Visibility.hidden()) {
- // Inactive tab? Skip fetch and schedule again
- schedule();
- } else {
- // Active tab! Fetch data and then schedule when settled
- // eslint-disable-next-line promise/catch-or-return
- this.fetchDashboardData().finally(schedule);
- }
- },
- stopAutoRefresh() {
- clearTimeout(this.timeoutId);
- this.timeoutId = null;
- },
-
- setRefreshInterval(option) {
- this.refreshInterval = option;
- },
- removeRefreshInterval() {
- this.refreshInterval = null;
- },
- isChecked(option) {
- if (this.refreshInterval) {
- return option.interval === this.refreshInterval.interval;
- }
- return false;
- },
- },
-
- refreshIntervals: [
- makeInterval(5),
- makeInterval(10),
- makeInterval(30),
- makeInterval(5, 'm'),
- makeInterval(30, 'm'),
- makeInterval(1, 'h'),
- makeInterval(2, 'h'),
- makeInterval(12, 'h'),
- makeInterval(1, 'd'),
- ],
-};
-</script>
-
-<template>
- <gl-button-group>
- <gl-button
- v-gl-tooltip
- class="gl-flex-grow-1"
- variant="default"
- :title="$options.i18n.refreshDashboard"
- :aria-label="$options.i18n.refreshDashboard"
- icon="retry"
- @click="refresh"
- />
- <gl-dropdown v-gl-tooltip :title="s__('Metrics|Set refresh rate')" :text="dropdownText">
- <gl-dropdown-item
- is-check-item
- :is-checked="refreshInterval === null"
- @click="removeRefreshInterval()"
- >{{ __('Off') }}</gl-dropdown-item
- >
- <gl-dropdown-divider />
- <gl-dropdown-item
- v-for="(option, i) in $options.refreshIntervals"
- :key="i"
- is-check-item
- :is-checked="isChecked(option)"
- @click="setRefreshInterval(option)"
- >{{ option.label }}</gl-dropdown-item
- >
- </gl-dropdown>
- </gl-button-group>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue
deleted file mode 100644
index ff0327f5f99..00000000000
--- a/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue
+++ /dev/null
@@ -1,53 +0,0 @@
-<script>
-import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui';
-
-export default {
- components: {
- GlFormGroup,
- GlDropdown,
- GlDropdownItem,
- },
- props: {
- name: {
- type: String,
- required: true,
- },
- label: {
- type: String,
- required: true,
- },
- value: {
- type: String,
- required: false,
- default: '',
- },
- options: {
- type: Object,
- required: true,
- },
- },
- computed: {
- text() {
- const selectedOpt = this.options.values?.find((opt) => opt.value === this.value);
- return selectedOpt?.text || this.value;
- },
- },
- methods: {
- onUpdate(value) {
- this.$emit('input', value);
- },
- },
-};
-</script>
-<template>
- <gl-form-group :label="label">
- <gl-dropdown toggle-class="dropdown-menu-toggle" :text="text || s__('Metrics|Select a value')">
- <gl-dropdown-item
- v-for="val in options.values"
- :key="val.value"
- @click="onUpdate(val.value)"
- >{{ val.text }}</gl-dropdown-item
- >
- </gl-dropdown>
- </gl-form-group>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/variables/text_field.vue b/app/assets/javascripts/monitoring/components/variables/text_field.vue
deleted file mode 100644
index a0418806e5f..00000000000
--- a/app/assets/javascripts/monitoring/components/variables/text_field.vue
+++ /dev/null
@@ -1,39 +0,0 @@
-<script>
-import { GlFormGroup, GlFormInput } from '@gitlab/ui';
-
-export default {
- components: {
- GlFormGroup,
- GlFormInput,
- },
- props: {
- name: {
- type: String,
- required: true,
- },
- label: {
- type: String,
- required: true,
- },
- value: {
- type: String,
- required: true,
- },
- },
- methods: {
- onUpdate(event) {
- this.$emit('input', event.target.value);
- },
- },
-};
-</script>
-<template>
- <gl-form-group :label="label">
- <gl-form-input
- :value="value"
- :name="name"
- @keyup.native.enter="onUpdate"
- @blur.native="onUpdate"
- />
- </gl-form-group>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/variables_section.vue b/app/assets/javascripts/monitoring/components/variables_section.vue
deleted file mode 100644
index 971f188e9f3..00000000000
--- a/app/assets/javascripts/monitoring/components/variables_section.vue
+++ /dev/null
@@ -1,53 +0,0 @@
-<script>
-import { mapState, mapActions } from 'vuex';
-import { VARIABLE_TYPES } from '../constants';
-import { setCustomVariablesFromUrl } from '../utils';
-import DropdownField from './variables/dropdown_field.vue';
-import TextField from './variables/text_field.vue';
-
-export default {
- components: {
- DropdownField,
- TextField,
- },
- computed: {
- ...mapState('monitoringDashboard', ['variables']),
- },
- methods: {
- ...mapActions('monitoringDashboard', ['updateVariablesAndFetchData']),
- refreshDashboard(variable, value) {
- if (variable.value !== value) {
- this.updateVariablesAndFetchData({ name: variable.name, value });
- // update the Vuex store
- // the below calls can ideally be moved out of the
- // component and into the actions and let the
- // mutation respond directly.
- // This can be further investigate in
- // https://gitlab.com/gitlab-org/gitlab/-/issues/217713
- setCustomVariablesFromUrl(this.variables);
- }
- },
- variableField(type) {
- if (type === VARIABLE_TYPES.custom || type === VARIABLE_TYPES.metric_label_values) {
- return DropdownField;
- }
- return TextField;
- },
- },
-};
-</script>
-<template>
- <div ref="variablesSection" class="d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 variables-section">
- <div v-for="variable in variables" :key="variable.name" class="mb-1 pr-2 d-flex d-sm-block">
- <component
- :is="variableField(variable.type)"
- class="mb-0 flex-grow-1"
- :label="variable.label"
- :value="variable.value"
- :name="variable.name"
- :options="variable.options"
- @input="refreshDashboard(variable, $event)"
- />
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
deleted file mode 100644
index e35dcc350f2..00000000000
--- a/app/assets/javascripts/monitoring/constants.js
+++ /dev/null
@@ -1,262 +0,0 @@
-export const PROMETHEUS_TIMEOUT = 120000; // TWO_MINUTES
-
-export const dashboardEmptyStates = {
- GETTING_STARTED: 'gettingStarted',
- LOADING: 'loading',
- NO_DATA: 'noData',
- UNABLE_TO_CONNECT: 'unableToConnect',
-};
-
-/**
- * States and error states in Prometheus Queries (PromQL) for metrics
- */
-export const metricStates = {
- /**
- * Metric data is available
- */
- OK: 'OK',
-
- /**
- * Metric data is being fetched for the first time.
- *
- * Not used during data refresh, if data is available in
- * the metric, the recommneded state is OK.
- */
- LOADING: 'LOADING',
-
- /**
- * Connection timed out to prometheus server
- * the timeout is set to PROMETHEUS_TIMEOUT
- *
- */
- TIMEOUT: 'TIMEOUT',
-
- /**
- * The prometheus server replies with an empty data set
- */
- NO_DATA: 'NO_DATA',
-
- /**
- * The prometheus server cannot be reached
- */
- CONNECTION_FAILED: 'CONNECTION_FAILED',
-
- /**
- * The prometheus server was reached but it cannot process
- * the query. This can happen for several reasons:
- * - PromQL syntax is incorrect
- * - An operator is not supported
- */
- BAD_QUERY: 'BAD_QUERY',
-
- /**
- * No specific reason found for error
- */
- UNKNOWN_ERROR: 'UNKNOWN_ERROR',
-};
-
-/**
- * Supported panel types in dashboards, values of `panel.type`.
- *
- * Values should not be changed as they correspond to
- * values in users the `.yml` dashboard definition.
- */
-export const panelTypes = {
- /**
- * Area Chart
- *
- * Time Series chart with an area
- */
- AREA_CHART: 'area-chart',
- /**
- * Line Chart
- *
- * Time Series chart with a line
- */
- LINE_CHART: 'line-chart',
- /**
- * Anomaly Chart
- *
- * Time Series chart with 3 metrics
- */
- ANOMALY_CHART: 'anomaly-chart',
- /**
- * Single Stat
- *
- * Single data point visualization
- */
- SINGLE_STAT: 'single-stat',
- /**
- * Gauge
- */
- GAUGE_CHART: 'gauge',
- /**
- * Heatmap
- */
- HEATMAP: 'heatmap',
- /**
- * Bar chart
- */
- BAR: 'bar',
- /**
- * Column chart
- */
- COLUMN: 'column',
- /**
- * Stacked column chart
- */
- STACKED_COLUMN: 'stacked-column',
-};
-
-export const sidebarAnimationDuration = 300; // milliseconds.
-export const chartHeight = 300;
-
-export const graphTypes = {
- annotationsData: 'scatter',
-};
-
-export const symbolSizes = {
- anomaly: 8,
- default: 14,
-};
-
-export const areaOpacityValues = {
- default: 0.2,
-};
-
-export const colorValues = {
- primaryColor: '#1f78d1', // $blue-500 (see variables.scss)
- anomalySymbol: '#db3b21',
- anomalyAreaColor: '#1f78d1',
-};
-
-export const lineTypes = {
- default: 'solid',
-};
-
-export const lineWidths = {
- default: 2,
-};
-
-/**
- * User-defined links can be passed in dashboard yml file.
- * These are the supported type of links.
- */
-export const linkTypes = {
- GRAFANA: 'grafana',
-};
-
-/**
- * These are the supported values for the GitLab-UI
- * chart legend layout.
- *
- * Currently defined in
- * https://gitlab.com/gitlab-org/gitlab-ui/-/blob/main/src/utils/charts/constants.js
- *
- */
-export const legendLayoutTypes = {
- inline: 'inline',
- table: 'table',
-};
-
-/**
- * These Vuex store properties are allowed to be
- * replaced dynamically after component has been created
- * and initial state has been set.
- *
- * Currently used in `receiveMetricsDashboardSuccess` action.
- */
-export const endpointKeys = [
- 'deploymentsEndpoint',
- 'dashboardEndpoint',
- 'dashboardsEndpoint',
- 'currentDashboard',
- 'projectPath',
-];
-
-/**
- * These Vuex store properties are set as soon as the
- * dashboard component has been created. The values are
- * passed as data-* attributes and received by dashboard
- * as Vue props.
- */
-export const initialStateKeys = [...endpointKeys, 'currentEnvironmentName'];
-
-/**
- * Constant to indicate if a metric exists in the database
- */
-export const NOT_IN_DB_PREFIX = 'NO_DB';
-
-/**
- * graphQL environments API value for active environments.
- * Used as a value for the 'states' query filter
- */
-export const ENVIRONMENT_AVAILABLE_STATE = 'available';
-
-/**
- * As of %12.10, the svg icon library does not have an annotation
- * arrow icon yet. In order to deliver annotations feature, the icon
- * is hard coded until the icon is added. The below issue is
- * to track the icon.
- *
- * https://gitlab.com/gitlab-org/gitlab-svgs/-/issues/118
- *
- * Once the icon is merged this can be removed.
- * https://gitlab.com/gitlab-org/gitlab/-/issues/214540
- */
-export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z';
-
-/**
- * As of %12.10, dashboard path is required to create annotation.
- * The FE gets the dashboard name from the URL params. It is not
- * ideal to store the path this way but there is no other way to
- * get this path unless annotations fetch is delayed. This could
- * potentially be removed and have the backend send this to the FE.
- *
- * This technical debt is being tracked here
- * https://gitlab.com/gitlab-org/gitlab/-/issues/214671
- */
-export const OVERVIEW_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml';
-
-/**
- * GitLab provide metrics dashboards that are available to a user once
- * the Prometheus managed app has been installed, without any extra setup
- * required. These "out of the box" dashboards are defined under the
- * `config/prometheus` path.
- */
-export const OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX = 'config/prometheus/';
-
-/**
- * Dashboard yml files support custom user-defined variables that
- * are rendered as input elements in the monitoring dashboard.
- * These values can be edited by the user and are passed on to the
- * the backend and eventually to Prometheus API proxy.
- *
- * As of 13.0, the supported types are:
- * simple custom -> dropdown elements
- * advanced custom -> dropdown elements
- * text -> text input elements
- *
- * Custom variables have a simple and a advanced variant.
- */
-export const VARIABLE_TYPES = {
- custom: 'custom',
- text: 'text',
- metric_label_values: 'metric_label_values',
-};
-
-/**
- * The names of templating variables defined in the dashboard yml
- * file are prefixed with a constant so that it doesn't collide with
- * other URL params that the monitoring dashboard relies on for
- * features like panel fullscreen etc.
- *
- * The prefix is added before it is appended to the URL and removed
- * before passing the data to the backend.
- */
-export const VARIABLE_PREFIX = 'var-';
-
-export const thresholdModeTypes = {
- ABSOLUTE: 'absolute',
- PERCENTAGE: 'percentage',
-};
diff --git a/app/assets/javascripts/monitoring/csv_export.js b/app/assets/javascripts/monitoring/csv_export.js
deleted file mode 100644
index 7e15b659767..00000000000
--- a/app/assets/javascripts/monitoring/csv_export.js
+++ /dev/null
@@ -1,146 +0,0 @@
-import { getSeriesLabel } from '~/helpers/monitor_helper';
-
-/**
- * Returns a label for a header of the csv.
- *
- * Includes double quotes ("") in case the header includes commas or other separator.
- *
- * @param {String} axisLabel
- * @param {String} metricLabel
- * @param {Object} metricAttributes
- */
-const csvHeader = (axisLabel, metricLabel, metricAttributes = {}) =>
- `${axisLabel} > ${getSeriesLabel(metricLabel, metricAttributes)}`;
-
-/**
- * Returns an array with the header labels given a list of metrics
- *
- * ```
- * metrics = [
- * {
- * label: "..." // user-defined label
- * result: [
- * {
- * metric: { ... } // metricAttributes
- * },
- * ...
- * ]
- * },
- * ...
- * ]
- * ```
- *
- * When metrics have a `label` or `metricAttributes`, they are
- * used to generate the column name.
- *
- * @param {String} axisLabel - Main label
- * @param {Array} metrics - Metrics with results
- */
-const csvMetricHeaders = (axisLabel, metrics) =>
- metrics.flatMap(({ label, result }) =>
- // The `metric` in a `result` is a map of `metricAttributes`
- // contains key-values to identify the series, rename it
- // here for clarity.
- result.map(({ metric: metricAttributes }) => {
- return csvHeader(axisLabel, label, metricAttributes);
- }),
- );
-
-/**
- * Returns a (flat) array with all the values arrays in each
- * metric and series
- *
- * ```
- * metrics = [
- * {
- * result: [
- * {
- * values: [ ... ] // `values`
- * },
- * ...
- * ]
- * },
- * ...
- * ]
- * ```
- *
- * @param {Array} metrics - Metrics with results
- */
-const csvMetricValues = (metrics) =>
- metrics.flatMap(({ result }) => result.map((res) => res.values || []));
-
-/**
- * Returns headers and rows for csv, sorted by their timestamp.
- *
- * {
- * headers: ["timestamp", "<col_1_name>", "col_2_name"],
- * rows: [
- * [ <timestamp>, <col_1_value>, <col_2_value> ],
- * [ <timestamp>, <col_1_value>, <col_2_value> ]
- * ...
- * ]
- * }
- *
- * @param {Array} metricHeaders
- * @param {Array} metricValues
- */
-const csvData = (metricHeaders, metricValues) => {
- const rowsByTimestamp = {};
-
- metricValues.forEach((values, colIndex) => {
- values.forEach(([timestamp, value]) => {
- if (!rowsByTimestamp[timestamp]) {
- rowsByTimestamp[timestamp] = [];
- }
- // `value` should be in the right column
- rowsByTimestamp[timestamp][colIndex] = value;
- });
- });
-
- const rows = Object.keys(rowsByTimestamp)
- .sort()
- .map((timestamp) => {
- // force each row to have the same number of entries
- rowsByTimestamp[timestamp].length = metricHeaders.length;
- // add timestamp as the first entry
- return [timestamp, ...rowsByTimestamp[timestamp]];
- });
-
- // Escape double quotes and enclose headers:
- // "If double-quotes are used to enclose fields, then a double-quote
- // appearing inside a field must be escaped by preceding it with
- // another double quote."
- // https://www.rfc-editor.org/rfc/rfc4180#page-2
- const headers = metricHeaders.map((header) => `"${header.replace(/"/g, '""')}"`);
-
- return {
- headers: ['timestamp', ...headers],
- rows,
- };
-};
-
-/**
- * Returns dashboard panel's data in a string in CSV format
- *
- * @param {Object} graphData - Panel contents
- * @returns {String}
- */
-export const graphDataToCsv = (graphData) => {
- const delimiter = ',';
- const br = '\r\n';
- const { metrics = [], y_label: axisLabel } = graphData;
-
- const metricsWithResults = metrics.filter((metric) => metric.result);
- const metricHeaders = csvMetricHeaders(axisLabel, metricsWithResults);
- const metricValues = csvMetricValues(metricsWithResults);
- const { headers, rows } = csvData(metricHeaders, metricValues);
-
- if (rows.length === 0) {
- return '';
- }
-
- const headerLine = headers.join(delimiter) + br;
- const lines = rows.map((row) => row.join(delimiter));
-
- return headerLine + lines.join(br) + br;
-};
diff --git a/app/assets/javascripts/monitoring/format_date.js b/app/assets/javascripts/monitoring/format_date.js
deleted file mode 100644
index f20fea48084..00000000000
--- a/app/assets/javascripts/monitoring/format_date.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import dateFormat from '~/lib/dateformat';
-
-export const timezones = {
- /**
- * Renders a date with a local timezone
- */
- LOCAL: 'LOCAL',
-
- /**
- * Renders at date with UTC
- */
- UTC: 'UTC',
-};
-
-export const formats = {
- shortTime: 'h:MM TT',
- shortDateTime: 'm/d h:MM TT',
- default: 'dd mmm yyyy, h:MMTT (Z)',
-};
-
-/**
- * Formats a date for a metric dashboard or chart.
- *
- * Convenience wrapper of dateFormat with default formats
- * and settings.
- *
- * dateFormat has some limitations and we could use `toLocaleString` instead
- * See: https://gitlab.com/gitlab-org/gitlab/-/issues/219246
- *
- * @param {Date|String|Number} date
- * @param {Object} options - Formatting options
- * @param {string} options.format - Format or mask from `formats`.
- * @param {string} options.timezone - Timezone abbreviation.
- * Accepts "LOCAL" for the client local timezone.
- */
-export const formatDate = (date, options = {}) => {
- const { format = formats.default, timezone = timezones.LOCAL } = options;
- const useUTC = timezone === timezones.UTC;
- return dateFormat(date, format, useUTC);
-};
diff --git a/app/assets/javascripts/monitoring/monitoring_app.js b/app/assets/javascripts/monitoring/monitoring_app.js
deleted file mode 100644
index ee67e5dd827..00000000000
--- a/app/assets/javascripts/monitoring/monitoring_app.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import { GlToast } from '@gitlab/ui';
-import Vue from 'vue';
-import createRouter from './router';
-import { createStore } from './stores';
-import { stateAndPropsFromDataset } from './utils';
-
-Vue.use(GlToast);
-
-export default (props = {}) => {
- const el = document.getElementById('prometheus-graphs');
-
- if (el && el.dataset) {
- const { metricsDashboardBasePath, ...dataset } = el.dataset;
-
- const { initState, dataProps } = stateAndPropsFromDataset(dataset);
- const store = createStore(initState);
- const router = createRouter(metricsDashboardBasePath);
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- store,
- router,
- data() {
- return {
- dashboardProps: { ...dataProps, ...props },
- };
- },
- render(h) {
- return h('RouterView', {
- // This is attrs rather than props because:
- // 1. RouterView only actually defines one prop: `name`.
- // 2. The RouterView [throws away other props][1] given to it, in
- // favour of those configured in the route config/params.
- // 3. The Vue template compiler itself in general compiles anything
- // like <some-component :foo="bar" /> into roughly
- // h('some-component', { attrs: { foo: bar } }). Then later, Vue
- // [extract props from attrs and merges them with props][2],
- // matching them up according to the component's definition.
- // [1]: https://github.com/vuejs/vue-router/blob/v3.4.9/src/components/view.js#L124
- // [2]: https://github.com/vuejs/vue/blob/v2.6.12/src/core/vdom/helpers/extract-props.js#L12-L50
- attrs: {
- dashboardProps: this.dashboardProps,
- },
- });
- },
- });
- }
-};
diff --git a/app/assets/javascripts/monitoring/monitoring_tracking_helper.js b/app/assets/javascripts/monitoring/monitoring_tracking_helper.js
deleted file mode 100644
index 5ae1eca10de..00000000000
--- a/app/assets/javascripts/monitoring/monitoring_tracking_helper.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import Tracking from '~/tracking';
-
-const trackDashboardLoad = ({ label, value }) =>
- Tracking.event(document.body.dataset.page, 'dashboard_fetch', {
- label,
- property: 'count',
- value,
- });
-
-export default trackDashboardLoad;
diff --git a/app/assets/javascripts/monitoring/pages/dashboard_page.vue b/app/assets/javascripts/monitoring/pages/dashboard_page.vue
deleted file mode 100644
index df0e2d7f8f6..00000000000
--- a/app/assets/javascripts/monitoring/pages/dashboard_page.vue
+++ /dev/null
@@ -1,29 +0,0 @@
-<script>
-import { mapActions } from 'vuex';
-import Dashboard from '../components/dashboard.vue';
-
-export default {
- components: {
- Dashboard,
- },
- props: {
- dashboardProps: {
- type: Object,
- required: true,
- },
- },
- created() {
- // This is to support the older URL <project>/-/environments/:env_id/metrics?dashboard=:path
- // and the new format <project>/-/metrics/:dashboardPath
- const encodedDashboard = this.$route.query.dashboard || this.$route.params.dashboard;
- const currentDashboard = encodedDashboard ? decodeURIComponent(encodedDashboard) : null;
- this.setCurrentDashboard({ currentDashboard });
- },
- methods: {
- ...mapActions('monitoringDashboard', ['setCurrentDashboard']),
- },
-};
-</script>
-<template>
- <dashboard v-bind="{ ...dashboardProps }" />
-</template>
diff --git a/app/assets/javascripts/monitoring/pages/panel_new_page.vue b/app/assets/javascripts/monitoring/pages/panel_new_page.vue
deleted file mode 100644
index dbda6e80dac..00000000000
--- a/app/assets/javascripts/monitoring/pages/panel_new_page.vue
+++ /dev/null
@@ -1,45 +0,0 @@
-<script>
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { mapState } from 'vuex';
-import { s__ } from '~/locale';
-import DashboardPanelBuilder from '../components/dashboard_panel_builder.vue';
-import { DASHBOARD_PAGE } from '../router/constants';
-
-export default {
- components: {
- GlButton,
- DashboardPanelBuilder,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- computed: {
- ...mapState('monitoringDashboard', ['panelPreviewYml']),
- dashboardPageLocation() {
- return {
- ...this.$route,
- name: DASHBOARD_PAGE,
- };
- },
- },
- i18n: {
- backToDashboard: s__('Metrics|Back to dashboard'),
- },
-};
-</script>
-<template>
- <div class="gl-mt-5">
- <div class="gl-display-flex gl-align-items-baseline gl-mb-5">
- <gl-button
- v-gl-tooltip
- icon="go-back"
- :to="dashboardPageLocation"
- :aria-label="$options.i18n.backToDashboard"
- :title="$options.i18n.backToDashboard"
- class="gl-mr-5"
- />
- <h1 class="gl-font-size-h1 gl-my-0">{{ s__('Metrics|Add panel') }}</h1>
- </div>
- <dashboard-panel-builder />
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/queries/get_annotations.query.graphql b/app/assets/javascripts/monitoring/queries/get_annotations.query.graphql
deleted file mode 100644
index 32b982ff195..00000000000
--- a/app/assets/javascripts/monitoring/queries/get_annotations.query.graphql
+++ /dev/null
@@ -1,27 +0,0 @@
-query getAnnotations(
- $projectPath: ID!
- $environmentName: String
- $dashboardPath: String!
- $startingFrom: Time!
-) {
- project(fullPath: $projectPath) {
- id
- environments(name: $environmentName) {
- nodes {
- id
- name
- metricsDashboard(path: $dashboardPath) {
- annotations(from: $startingFrom) {
- nodes {
- id
- description
- startingAt
- endingAt
- panelId
- }
- }
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/monitoring/queries/get_dashboard_validation_warnings.query.graphql b/app/assets/javascripts/monitoring/queries/get_dashboard_validation_warnings.query.graphql
deleted file mode 100644
index a61d601cd34..00000000000
--- a/app/assets/javascripts/monitoring/queries/get_dashboard_validation_warnings.query.graphql
+++ /dev/null
@@ -1,19 +0,0 @@
-query getDashboardValidationWarnings(
- $projectPath: ID!
- $environmentName: String
- $dashboardPath: String!
-) {
- project(fullPath: $projectPath) {
- id
- environments(name: $environmentName) {
- nodes {
- id
- name
- metricsDashboard(path: $dashboardPath) {
- path
- schemaValidationWarnings
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/monitoring/queries/get_environments.query.graphql b/app/assets/javascripts/monitoring/queries/get_environments.query.graphql
deleted file mode 100644
index 48d0a780fc7..00000000000
--- a/app/assets/javascripts/monitoring/queries/get_environments.query.graphql
+++ /dev/null
@@ -1,11 +0,0 @@
-query getEnvironments($projectPath: ID!, $search: String, $states: [String!]) {
- project(fullPath: $projectPath) {
- id
- data: environments(search: $search, states: $states) {
- environments: nodes {
- name
- id
- }
- }
- }
-}
diff --git a/app/assets/javascripts/monitoring/requests/index.js b/app/assets/javascripts/monitoring/requests/index.js
deleted file mode 100644
index 29786a79c56..00000000000
--- a/app/assets/javascripts/monitoring/requests/index.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-import { backOff } from '~/lib/utils/common_utils';
-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';
-
-const cancellableBackOffRequest = (makeRequestCallback) =>
- backOff((next, stop) => {
- makeRequestCallback()
- .then((resp) => {
- if (resp.status === HTTP_STATUS_NO_CONTENT) {
- next();
- } else {
- stop(resp);
- }
- })
- // If the request is cancelled by axios
- // then consider it as noop so that its not
- // caught by subsequent catches
- .catch((thrown) => (axios.isCancel(thrown) ? undefined : stop(thrown)));
- }, PROMETHEUS_TIMEOUT);
-
-export const getDashboard = (dashboardEndpoint, params) =>
- cancellableBackOffRequest(() => axios.get(dashboardEndpoint, { params })).then(
- (axiosResponse) => axiosResponse.data,
- );
-
-export const getPrometheusQueryData = (prometheusEndpoint, params, opts) =>
- cancellableBackOffRequest(() => axios.get(prometheusEndpoint, { params, ...opts }))
- .then((axiosResponse) => axiosResponse.data)
- .then((prometheusResponse) => prometheusResponse.data)
- .catch((error) => {
- // Prometheus returns errors in specific cases
- // https://prometheus.io/docs/prometheus/latest/querying/api/#format-overview
- const { response = {} } = error;
- if (
- response.status === HTTP_STATUS_BAD_REQUEST ||
- response.status === HTTP_STATUS_UNPROCESSABLE_ENTITY ||
- response.status === HTTP_STATUS_SERVICE_UNAVAILABLE
- ) {
- const { data } = response;
- if (data?.status === 'error' && data?.error) {
- throw new Error(data.error);
- }
- }
- throw error;
- });
diff --git a/app/assets/javascripts/monitoring/router/constants.js b/app/assets/javascripts/monitoring/router/constants.js
deleted file mode 100644
index 7834c14a65d..00000000000
--- a/app/assets/javascripts/monitoring/router/constants.js
+++ /dev/null
@@ -1,7 +0,0 @@
-export const DASHBOARD_PAGE = 'dashboard';
-export const PANEL_NEW_PAGE = 'panel_new';
-
-export default {
- DASHBOARD_PAGE,
- PANEL_NEW_PAGE,
-};
diff --git a/app/assets/javascripts/monitoring/router/index.js b/app/assets/javascripts/monitoring/router/index.js
deleted file mode 100644
index 12692612bbc..00000000000
--- a/app/assets/javascripts/monitoring/router/index.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import Vue from 'vue';
-import VueRouter from 'vue-router';
-import routes from './routes';
-
-Vue.use(VueRouter);
-
-export default function createRouter(base) {
- const router = new VueRouter({
- base,
- mode: 'history',
- routes,
- });
-
- return router;
-}
diff --git a/app/assets/javascripts/monitoring/router/routes.js b/app/assets/javascripts/monitoring/router/routes.js
deleted file mode 100644
index cc43fd8622a..00000000000
--- a/app/assets/javascripts/monitoring/router/routes.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import DashboardPage from '../pages/dashboard_page.vue';
-import PanelNewPage from '../pages/panel_new_page.vue';
-
-import { DASHBOARD_PAGE, PANEL_NEW_PAGE } from './constants';
-
-/**
- * Because the cluster health page uses the dashboard
- * app instead the of the dashboard component, hitting
- * `/` route is not possible. Hence using `*` until the
- * health page is refactored.
- * https://gitlab.com/gitlab-org/gitlab/-/issues/221096
- */
-export default [
- {
- name: PANEL_NEW_PAGE,
- path: '/:dashboard(.+)?/panel/new',
- component: PanelNewPage,
- },
- {
- name: DASHBOARD_PAGE,
- path: '/:dashboard(.+)?',
- component: DashboardPage,
- },
-];
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
deleted file mode 100644
index 32e85262882..00000000000
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ /dev/null
@@ -1,576 +0,0 @@
-import * as Sentry from '@sentry/browser';
-import { createAlert } from '~/alert';
-import axios from '~/lib/utils/axios_utils';
-import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { s__, sprintf } from '~/locale';
-import { ENVIRONMENT_AVAILABLE_STATE, OVERVIEW_DASHBOARD_PATH, VARIABLE_TYPES } from '../constants';
-import trackDashboardLoad from '../monitoring_tracking_helper';
-import getAnnotations from '../queries/get_annotations.query.graphql';
-import getDashboardValidationWarnings from '../queries/get_dashboard_validation_warnings.query.graphql';
-import getEnvironments from '../queries/get_environments.query.graphql';
-import { getDashboard, getPrometheusQueryData } from '../requests';
-
-import * as types from './mutation_types';
-import {
- gqClient,
- parseEnvironmentsResponse,
- parseAnnotationsResponse,
- removeLeadingSlash,
-} from './utils';
-
-const axiosCancelToken = axios.CancelToken;
-let cancelTokenSource;
-
-function prometheusMetricQueryParams(timeRange) {
- const { start, end } = convertToFixedRange(timeRange);
-
- const timeDiff = (new Date(end) - new Date(start)) / 1000;
- const minStep = 60;
- const queryDataPoints = 600;
-
- return {
- start_time: start,
- end_time: end,
- step: Math.max(minStep, Math.ceil(timeDiff / queryDataPoints)),
- };
-}
-
-/**
- * Extract error messages from API or HTTP request errors.
- *
- * - API errors are in `error.response.data.message`
- * - HTTP (axios) errors are in `error.message`
- *
- * @param {Object} error
- * @returns {String} User friendly error message
- */
-function extractErrorMessage(error) {
- const message = error?.response?.data?.message;
- return message ?? error.message;
-}
-
-// Setup
-
-export const setGettingStartedEmptyState = ({ commit }) => {
- commit(types.SET_GETTING_STARTED_EMPTY_STATE);
-};
-
-export const setInitialState = ({ commit }, initialState) => {
- commit(types.SET_INITIAL_STATE, initialState);
-};
-
-export const setTimeRange = ({ commit }, timeRange) => {
- commit(types.SET_TIME_RANGE, timeRange);
-};
-
-export const filterEnvironments = ({ commit, dispatch }, searchTerm) => {
- commit(types.SET_ENVIRONMENTS_FILTER, searchTerm);
- dispatch('fetchEnvironmentsData');
-};
-
-export const setShowErrorBanner = ({ commit }, enabled) => {
- commit(types.SET_SHOW_ERROR_BANNER, enabled);
-};
-
-export const setExpandedPanel = ({ commit }, { group, panel }) => {
- commit(types.SET_EXPANDED_PANEL, { group, panel });
-};
-
-export const clearExpandedPanel = ({ commit }) => {
- commit(types.SET_EXPANDED_PANEL, {
- group: null,
- panel: null,
- });
-};
-
-export const setCurrentDashboard = ({ commit }, { currentDashboard }) => {
- commit(types.SET_CURRENT_DASHBOARD, currentDashboard);
-};
-
-// All Data
-
-/**
- * Fetch all dashboard data.
- *
- * @param {Object} store
- * @returns A promise that resolves when the dashboard
- * skeleton has been loaded.
- */
-export const fetchData = ({ dispatch }) => {
- dispatch('fetchEnvironmentsData');
- dispatch('fetchDashboard');
- dispatch('fetchAnnotations');
-};
-
-// Metrics dashboard
-
-export const fetchDashboard = ({ state, commit, dispatch, getters }) => {
- dispatch('requestMetricsDashboard');
-
- const params = {};
- if (getters.fullDashboardPath) {
- params.dashboard = getters.fullDashboardPath;
- }
-
- return getDashboard(state.dashboardEndpoint, params)
- .then((response) => {
- dispatch('receiveMetricsDashboardSuccess', { response });
- /**
- * After the dashboard is fetched, there can be non-blocking invalid syntax
- * in the dashboard file. This call will fetch such syntax warnings
- * and surface a warning on the UI. If the invalid syntax is blocking,
- * the `fetchDashboard` returns a 404 with error messages that are displayed
- * on the UI.
- */
- dispatch('fetchDashboardValidationWarnings');
- })
- .catch((error) => {
- Sentry.captureException(error);
-
- commit(types.SET_ALL_DASHBOARDS, error.response?.data?.all_dashboards ?? []);
- dispatch('receiveMetricsDashboardFailure', error);
-
- if (state.showErrorBanner) {
- if (error.response.data && error.response.data.message) {
- const { message } = error.response.data;
- createAlert({
- message: sprintf(
- s__('Metrics|There was an error while retrieving metrics. %{message}'),
- { message },
- false,
- ),
- });
- } else {
- createAlert({
- message: s__('Metrics|There was an error while retrieving metrics'),
- });
- }
- }
- });
-};
-
-export const requestMetricsDashboard = ({ commit }) => {
- commit(types.REQUEST_METRICS_DASHBOARD);
-};
-export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response }) => {
- const { all_dashboards, dashboard, metrics_data } = response;
-
- commit(types.SET_ALL_DASHBOARDS, all_dashboards);
- commit(types.RECEIVE_METRICS_DASHBOARD_SUCCESS, dashboard);
- commit(types.SET_ENDPOINTS, convertObjectPropsToCamelCase(metrics_data));
-
- return dispatch('fetchDashboardData');
-};
-export const receiveMetricsDashboardFailure = ({ commit }, error) => {
- commit(types.RECEIVE_METRICS_DASHBOARD_FAILURE, error);
-};
-
-// Metrics
-
-/**
- * Loads timeseries data: Prometheus data points and deployment data from the project
- * @param {Object} Vuex store
- */
-export const fetchDashboardData = ({ state, dispatch, getters }) => {
- dispatch('fetchDeploymentsData');
-
- if (!state.timeRange) {
- createAlert({
- message: s__(`Metrics|Invalid time range, please verify.`),
- type: 'warning',
- });
- return Promise.reject();
- }
-
- // Time range params must be pre-calculated once for all metrics and options
- // A subsequent call, may calculate a different time range
- const defaultQueryParams = prometheusMetricQueryParams(state.timeRange);
-
- dispatch('fetchVariableMetricLabelValues', { defaultQueryParams });
-
- const promises = [];
- state.dashboard.panelGroups.forEach((group) => {
- group.panels.forEach((panel) => {
- panel.metrics.forEach((metric) => {
- promises.push(dispatch('fetchPrometheusMetric', { metric, defaultQueryParams }));
- });
- });
- });
-
- return Promise.all(promises)
- .then(() => {
- const dashboardType = getters.fullDashboardPath === '' ? 'default' : 'custom';
- trackDashboardLoad({
- label: `${dashboardType}_metrics_dashboard`,
- value: getters.metricsWithData().length,
- });
- })
- .catch(() => {
- createAlert({
- message: s__(`Metrics|There was an error while retrieving metrics`),
- type: 'warning',
- });
- });
-};
-
-/**
- * Returns list of metrics in data.result
- * {"status":"success", "data":{"resultType":"matrix","result":[]}}
- *
- * @param {metric} metric
- */
-export const fetchPrometheusMetric = (
- { commit, state, getters },
- { metric, defaultQueryParams },
-) => {
- let queryParams = { ...defaultQueryParams };
- if (metric.step) {
- queryParams.step = metric.step;
- }
-
- if (state.variables.length > 0) {
- queryParams = {
- ...queryParams,
- ...getters.getCustomVariablesParams,
- };
- }
-
- commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metricId });
-
- return getPrometheusQueryData(metric.prometheusEndpointPath, queryParams)
- .then((data) => {
- commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metricId, data });
- })
- .catch((error) => {
- Sentry.captureException(error);
-
- commit(types.RECEIVE_METRIC_RESULT_FAILURE, { metricId: metric.metricId, error });
- // Continue to throw error so the dashboard can notify using createAlert
- throw error;
- });
-};
-
-// Deployments
-
-export const fetchDeploymentsData = ({ state, dispatch }) => {
- if (!state.deploymentsEndpoint) {
- return Promise.resolve([]);
- }
- return axios
- .get(state.deploymentsEndpoint)
- .then((resp) => resp.data)
- .then((response) => {
- if (!response || !response.deployments) {
- createAlert({
- message: s__('Metrics|Unexpected deployment data response from prometheus endpoint'),
- });
- }
-
- dispatch('receiveDeploymentsDataSuccess', response.deployments);
- })
- .catch((error) => {
- Sentry.captureException(error);
- dispatch('receiveDeploymentsDataFailure');
- createAlert({
- message: s__('Metrics|There was an error getting deployment information.'),
- });
- });
-};
-export const receiveDeploymentsDataSuccess = ({ commit }, data) => {
- commit(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, data);
-};
-export const receiveDeploymentsDataFailure = ({ commit }) => {
- commit(types.RECEIVE_DEPLOYMENTS_DATA_FAILURE);
-};
-
-// Environments
-
-export const fetchEnvironmentsData = ({ state, dispatch }) => {
- dispatch('requestEnvironmentsData');
- return gqClient
- .mutate({
- mutation: getEnvironments,
- variables: {
- projectPath: removeLeadingSlash(state.projectPath),
- search: state.environmentsSearchTerm,
- states: [ENVIRONMENT_AVAILABLE_STATE],
- },
- })
- .then((resp) =>
- parseEnvironmentsResponse(resp.data?.project?.data?.environments, state.projectPath),
- )
- .then((environments) => {
- if (!environments) {
- createAlert({
- message: s__(
- 'Metrics|There was an error fetching the environments data, please try again',
- ),
- });
- }
-
- dispatch('receiveEnvironmentsDataSuccess', environments);
- })
- .catch((err) => {
- Sentry.captureException(err);
- dispatch('receiveEnvironmentsDataFailure');
- createAlert({
- message: s__('Metrics|There was an error getting environments information.'),
- });
- });
-};
-export const requestEnvironmentsData = ({ commit }) => {
- commit(types.REQUEST_ENVIRONMENTS_DATA);
-};
-export const receiveEnvironmentsDataSuccess = ({ commit }, data) => {
- commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data);
-};
-export const receiveEnvironmentsDataFailure = ({ commit }) => {
- commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE);
-};
-
-export const fetchAnnotations = ({ state, dispatch, getters }) => {
- const { start } = convertToFixedRange(state.timeRange);
- const dashboardPath = getters.fullDashboardPath || OVERVIEW_DASHBOARD_PATH;
- return gqClient
- .mutate({
- mutation: getAnnotations,
- variables: {
- projectPath: removeLeadingSlash(state.projectPath),
- environmentName: state.currentEnvironmentName,
- dashboardPath,
- startingFrom: start,
- },
- })
- .then(
- (resp) => resp.data?.project?.environments?.nodes?.[0].metricsDashboard?.annotations.nodes,
- )
- .then(parseAnnotationsResponse)
- .then((annotations) => {
- if (!annotations) {
- createAlert({
- message: s__('Metrics|There was an error fetching annotations. Please try again.'),
- });
- }
-
- dispatch('receiveAnnotationsSuccess', annotations);
- })
- .catch((err) => {
- Sentry.captureException(err);
- dispatch('receiveAnnotationsFailure');
- createAlert({
- message: s__('Metrics|There was an error getting annotations information.'),
- });
- });
-};
-
-export const receiveAnnotationsSuccess = ({ commit }, data) =>
- commit(types.RECEIVE_ANNOTATIONS_SUCCESS, data);
-export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_ANNOTATIONS_FAILURE);
-
-export const fetchDashboardValidationWarnings = ({ state, dispatch, getters }) => {
- /**
- * Normally, the overview dashboard won't throw any validation warnings.
- *
- * However, if a bug sneaks into the overview dashboard making it invalid,
- * this might come handy for our clients
- */
- const dashboardPath = getters.fullDashboardPath || OVERVIEW_DASHBOARD_PATH;
- return gqClient
- .mutate({
- mutation: getDashboardValidationWarnings,
- variables: {
- projectPath: removeLeadingSlash(state.projectPath),
- environmentName: state.currentEnvironmentName,
- dashboardPath,
- },
- })
- .then((resp) => resp.data?.project?.environments?.nodes?.[0]?.metricsDashboard || undefined)
- .then(({ schemaValidationWarnings } = {}) => {
- const hasWarnings = schemaValidationWarnings && schemaValidationWarnings.length !== 0;
- /**
- * The payload of the dispatch is a boolean, because at the moment a standard
- * warning message is shown instead of the warnings the BE returns
- */
- dispatch('receiveDashboardValidationWarningsSuccess', hasWarnings || false);
- })
- .catch((err) => {
- Sentry.captureException(err);
- dispatch('receiveDashboardValidationWarningsFailure');
- createAlert({
- message: s__(
- 'Metrics|There was an error getting dashboard validation warnings information.',
- ),
- });
- });
-};
-
-export const receiveDashboardValidationWarningsSuccess = ({ commit }, hasWarnings) =>
- commit(types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS, hasWarnings);
-export const receiveDashboardValidationWarningsFailure = ({ commit }) =>
- commit(types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE);
-
-// Dashboard manipulation
-
-export const toggleStarredValue = ({ commit, state, getters }) => {
- const { selectedDashboard } = getters;
-
- if (state.isUpdatingStarredValue) {
- // Prevent repeating requests for the same change
- return;
- }
- if (!selectedDashboard) {
- return;
- }
-
- const method = selectedDashboard.starred ? 'DELETE' : 'POST';
- const url = selectedDashboard.user_starred_path;
- const newStarredValue = !selectedDashboard.starred;
-
- commit(types.REQUEST_DASHBOARD_STARRING);
-
- axios({
- url,
- method,
- })
- .then(() => {
- commit(types.RECEIVE_DASHBOARD_STARRING_SUCCESS, { selectedDashboard, newStarredValue });
- })
- .catch(() => {
- commit(types.RECEIVE_DASHBOARD_STARRING_FAILURE);
- });
-};
-
-/**
- * Set a new array of metrics to a panel group
- * @param {*} data An object containing
- * - `key` with a unique panel key
- * - `metrics` with the metrics array
- */
-export const setPanelGroupMetrics = ({ commit }, data) => {
- commit(types.SET_PANEL_GROUP_METRICS, data);
-};
-
-export const duplicateSystemDashboard = ({ state }, payload) => {
- const params = {
- dashboard: payload.dashboard,
- file_name: payload.fileName,
- branch: payload.branch,
- commit_message: payload.commitMessage,
- };
-
- return axios
- .post(state.dashboardsEndpoint, params)
- .then((response) => response.data)
- .then((data) => data.dashboard)
- .catch((error) => {
- Sentry.captureException(error);
-
- const { response } = error;
-
- if (response && response.data && response.data.error) {
- throw sprintf(s__('Metrics|There was an error creating the dashboard. %{error}'), {
- error: response.data.error,
- });
- } else {
- throw s__('Metrics|There was an error creating the dashboard.');
- }
- });
-};
-
-// Variables manipulation
-
-export const updateVariablesAndFetchData = ({ commit, dispatch }, updatedVariable) => {
- commit(types.UPDATE_VARIABLE_VALUE, updatedVariable);
-
- return dispatch('fetchDashboardData');
-};
-
-export const fetchVariableMetricLabelValues = ({ state, commit }, { defaultQueryParams }) => {
- const { start_time, end_time } = defaultQueryParams;
- const optionsRequests = [];
-
- state.variables.forEach((variable) => {
- if (variable.type === VARIABLE_TYPES.metric_label_values) {
- const { prometheusEndpointPath, label } = variable.options;
-
- const optionsRequest = getPrometheusQueryData(prometheusEndpointPath, {
- start_time,
- end_time,
- })
- .then((data) => {
- commit(types.UPDATE_VARIABLE_METRIC_LABEL_VALUES, { variable, label, data });
- })
- .catch(() => {
- createAlert({
- message: sprintf(
- s__('Metrics|There was an error getting options for variable "%{name}".'),
- {
- name: variable.name,
- },
- ),
- });
- });
- optionsRequests.push(optionsRequest);
- }
- });
-
- return Promise.all(optionsRequests);
-};
-
-// Panel Builder
-
-export const setPanelPreviewTimeRange = ({ commit }, timeRange) => {
- commit(types.SET_PANEL_PREVIEW_TIME_RANGE, timeRange);
-};
-
-export const fetchPanelPreview = ({ state, commit, dispatch }, panelPreviewYml) => {
- if (!panelPreviewYml) {
- return null;
- }
-
- commit(types.SET_PANEL_PREVIEW_IS_SHOWN, true);
- commit(types.REQUEST_PANEL_PREVIEW, panelPreviewYml);
-
- return axios
- .post(state.panelPreviewEndpoint, { panel_yaml: panelPreviewYml })
- .then(({ data }) => {
- commit(types.RECEIVE_PANEL_PREVIEW_SUCCESS, data);
-
- dispatch('fetchPanelPreviewMetrics');
- })
- .catch((error) => {
- commit(types.RECEIVE_PANEL_PREVIEW_FAILURE, extractErrorMessage(error));
- });
-};
-
-export const fetchPanelPreviewMetrics = ({ state, commit }) => {
- if (cancelTokenSource) {
- cancelTokenSource.cancel();
- }
- cancelTokenSource = axiosCancelToken.source();
-
- const defaultQueryParams = prometheusMetricQueryParams(state.panelPreviewTimeRange);
-
- state.panelPreviewGraphData.metrics.forEach((metric, index) => {
- commit(types.REQUEST_PANEL_PREVIEW_METRIC_RESULT, { index });
-
- const params = { ...defaultQueryParams };
- if (metric.step) {
- params.step = metric.step;
- }
- return getPrometheusQueryData(metric.prometheusEndpointPath, params, {
- cancelToken: cancelTokenSource.token,
- })
- .then((data) => {
- commit(types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS, { index, data });
- })
- .catch((error) => {
- Sentry.captureException(error);
-
- commit(types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE, { index, error });
- // Continue to throw error so the panel builder can notify using createAlert
- throw error;
- });
- });
-};
diff --git a/app/assets/javascripts/monitoring/stores/embed_group/actions.js b/app/assets/javascripts/monitoring/stores/embed_group/actions.js
deleted file mode 100644
index ca0d2e5ba35..00000000000
--- a/app/assets/javascripts/monitoring/stores/embed_group/actions.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import * as types from './mutation_types';
-
-export const addModule = ({ commit }, data) => commit(types.ADD_MODULE, data);
diff --git a/app/assets/javascripts/monitoring/stores/embed_group/getters.js b/app/assets/javascripts/monitoring/stores/embed_group/getters.js
deleted file mode 100644
index 8eddd830c58..00000000000
--- a/app/assets/javascripts/monitoring/stores/embed_group/getters.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export const metricsWithData = (state, getters, rootState, rootGetters) =>
- state.modules.map((module) => rootGetters[`${module}/metricsWithData`]().length);
diff --git a/app/assets/javascripts/monitoring/stores/embed_group/index.js b/app/assets/javascripts/monitoring/stores/embed_group/index.js
deleted file mode 100644
index 66c65adc413..00000000000
--- a/app/assets/javascripts/monitoring/stores/embed_group/index.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import * as actions from './actions';
-import * as getters from './getters';
-import mutations from './mutations';
-import state from './state';
-
-Vue.use(Vuex);
-
-// In practice this store will have a number of `monitoringDashboard` modules added dynamically
-export const createStore = () =>
- new Vuex.Store({
- modules: {
- embedGroup: {
- namespaced: true,
- actions,
- getters,
- mutations,
- state,
- },
- },
- });
diff --git a/app/assets/javascripts/monitoring/stores/embed_group/mutation_types.js b/app/assets/javascripts/monitoring/stores/embed_group/mutation_types.js
deleted file mode 100644
index 288e6db4151..00000000000
--- a/app/assets/javascripts/monitoring/stores/embed_group/mutation_types.js
+++ /dev/null
@@ -1 +0,0 @@
-export const ADD_MODULE = 'ADD_MODULE';
diff --git a/app/assets/javascripts/monitoring/stores/embed_group/mutations.js b/app/assets/javascripts/monitoring/stores/embed_group/mutations.js
deleted file mode 100644
index 3c66129f239..00000000000
--- a/app/assets/javascripts/monitoring/stores/embed_group/mutations.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import * as types from './mutation_types';
-
-export default {
- [types.ADD_MODULE](state, module) {
- state.modules.push(module);
- },
-};
diff --git a/app/assets/javascripts/monitoring/stores/embed_group/state.js b/app/assets/javascripts/monitoring/stores/embed_group/state.js
deleted file mode 100644
index 016c7e5dac7..00000000000
--- a/app/assets/javascripts/monitoring/stores/embed_group/state.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export default () => ({
- modules: [],
-});
diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js
deleted file mode 100644
index d6a04006264..00000000000
--- a/app/assets/javascripts/monitoring/stores/getters.js
+++ /dev/null
@@ -1,174 +0,0 @@
-import { NOT_IN_DB_PREFIX } from '../constants';
-import {
- addPrefixToCustomVariableParams,
- addDashboardMetaDataToLink,
- normalizeCustomDashboardPath,
-} from './utils';
-
-const metricsIdsInPanel = (panel) =>
- panel.metrics
- .filter((metric) => metric.metricId && metric.result)
- .map((metric) => metric.metricId);
-
-/**
- * Returns a reference to the currently selected dashboard
- * from the list of dashboards.
- *
- * @param {Object} state
- */
-export const selectedDashboard = (state, getters) => {
- const { allDashboards } = state;
- return (
- allDashboards.find((d) => d.path === getters.fullDashboardPath) ||
- allDashboards.find((d) => d.default) ||
- null
- );
-};
-
-/**
- * Get all state for metric in the dashboard or a group. The
- * states are not repeated so the dashboard or group can show
- * a global state.
- *
- * @param {Object} state
- * @returns {Function} A function that returns an array of
- * states in all the metric in the dashboard or group.
- */
-export const getMetricStates = (state) => (groupKey) => {
- let groups = state.dashboard.panelGroups;
- if (groupKey) {
- groups = groups.filter((group) => group.key === groupKey);
- }
-
- const metricStates = groups.reduce((acc, group) => {
- group.panels.forEach((panel) => {
- panel.metrics.forEach((metric) => {
- if (metric.state) {
- acc.push(metric.state);
- }
- });
- });
- return acc;
- }, []);
-
- // Deduplicate and sort array
- return Array.from(new Set(metricStates)).sort();
-};
-
-/**
- * Getter to obtain the list of metric ids that have data
- *
- * Useful to understand which parts of the dashboard should
- * be displayed. It is a Vuex Method-Style Access getter.
- *
- * @param {Object} state
- * @returns {Function} A function that returns an array of
- * metrics in the dashboard that contain results, optionally
- * filtered by group key.
- */
-export const metricsWithData = (state) => (groupKey) => {
- let groups = state.dashboard.panelGroups;
- if (groupKey) {
- groups = groups.filter((group) => group.key === groupKey);
- }
-
- const res = [];
- groups.forEach((group) => {
- group.panels.forEach((panel) => {
- res.push(...metricsIdsInPanel(panel));
- });
- });
-
- return res;
-};
-
-/**
- * Metrics loaded from project-defined dashboards do not have a metric_id.
- * This getter checks which metrics are stored in the db (have a metric id)
- * This is hopefully a temporary solution until BE processes metrics before passing to FE
- *
- * Related:
- * https://gitlab.com/gitlab-org/gitlab/-/issues/28241
- * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27447
- */
-export const metricsSavedToDb = (state) => {
- const metricIds = [];
- state.dashboard.panelGroups.forEach(({ panels }) => {
- panels.forEach(({ metrics }) => {
- const metricIdsInDb = metrics
- .filter(({ metricId }) => !metricId.startsWith(NOT_IN_DB_PREFIX))
- .map(({ metricId }) => metricId);
-
- metricIds.push(...metricIdsInDb);
- });
- });
- return metricIds;
-};
-
-/**
- * Filter environments by names.
- *
- * This is used in the environments dropdown with searchable input.
- *
- * @param {Object} state
- * @returns {Array} List of environments
- */
-export const filteredEnvironments = (state) =>
- state.environments.filter((env) =>
- env.name.toLowerCase().includes((state.environmentsSearchTerm || '').trim().toLowerCase()),
- );
-
-/**
- * User-defined links from the yml file can have other
- * dashboard-related metadata baked into it. This method
- * returns modified links which will get rendered in the
- * metrics dashboard
- *
- * @param {Object} state
- * @returns {Array} modified array of links
- */
-export const linksWithMetadata = (state) => {
- const metadata = {
- timeRange: state.timeRange,
- };
- return state.links?.map(addDashboardMetaDataToLink(metadata));
-};
-
-/**
- * Maps a variables array to an object for replacement in
- * prometheus queries.
- *
- * This method outputs an object in the below format
- *
- * {
- * variables[key1]=value1,
- * variables[key2]=value2,
- * }
- *
- * This is done so that the backend can identify the custom
- * user-defined variables coming through the URL and differentiate
- * from other variables used for Prometheus API endpoint.
- *
- * @param {Object} state - State containing variables provided by the user
- * @returns {Array} The custom variables object to be send to the API
- * in the format of {variables[key1]=value1, variables[key2]=value2}
- */
-
-export const getCustomVariablesParams = (state) =>
- state.variables.reduce((acc, variable) => {
- const { name, value } = variable;
- if (value !== null) {
- acc[addPrefixToCustomVariableParams(name)] = value;
- }
- return acc;
- }, {});
-
-/**
- * For a given custom dashboard file name, this method
- * returns the full file path.
- *
- * @param {Object} state
- * @returns {String} full dashboard path
- */
-export const fullDashboardPath = (state) =>
- normalizeCustomDashboardPath(state.currentDashboard, state.customDashboardBasePath);
diff --git a/app/assets/javascripts/monitoring/stores/index.js b/app/assets/javascripts/monitoring/stores/index.js
deleted file mode 100644
index 213a8508aa2..00000000000
--- a/app/assets/javascripts/monitoring/stores/index.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import * as actions from './actions';
-import * as getters from './getters';
-import mutations from './mutations';
-import state from './state';
-
-Vue.use(Vuex);
-
-export const monitoringDashboard = {
- namespaced: true,
- actions,
- getters,
- mutations,
- state,
-};
-
-export const createStore = (initState = {}) =>
- new Vuex.Store({
- modules: {
- monitoringDashboard: {
- ...monitoringDashboard,
- state: {
- ...state(),
- ...initState,
- },
- },
- },
- });
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
deleted file mode 100644
index 1d7279912cc..00000000000
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ /dev/null
@@ -1,62 +0,0 @@
-// Dashboard "skeleton", groups, panels, metrics, query variables
-export const REQUEST_METRICS_DASHBOARD = 'REQUEST_METRICS_DASHBOARD';
-export const RECEIVE_METRICS_DASHBOARD_SUCCESS = 'RECEIVE_METRICS_DASHBOARD_SUCCESS';
-export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE';
-export const UPDATE_VARIABLE_VALUE = 'UPDATE_VARIABLE_VALUE';
-export const UPDATE_VARIABLE_METRIC_LABEL_VALUES = 'UPDATE_VARIABLE_METRIC_LABEL_VALUES';
-
-export const REQUEST_DASHBOARD_STARRING = 'REQUEST_DASHBOARD_STARRING';
-export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS';
-export const RECEIVE_DASHBOARD_STARRING_FAILURE = 'RECEIVE_DASHBOARD_STARRING_FAILURE';
-
-export const SET_CURRENT_DASHBOARD = 'SET_CURRENT_DASHBOARD';
-
-// Annotations
-export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS';
-export const RECEIVE_ANNOTATIONS_FAILURE = 'RECEIVE_ANNOTATIONS_FAILURE';
-
-// Dashboard validation warnings
-export const RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS =
- 'RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS';
-export const RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE =
- 'RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE';
-
-// Git project deployments
-export const REQUEST_DEPLOYMENTS_DATA = 'REQUEST_DEPLOYMENTS_DATA';
-export const RECEIVE_DEPLOYMENTS_DATA_SUCCESS = 'RECEIVE_DEPLOYMENTS_DATA_SUCCESS';
-export const RECEIVE_DEPLOYMENTS_DATA_FAILURE = 'RECEIVE_DEPLOYMENTS_DATA_FAILURE';
-
-// Environments
-export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA';
-export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS';
-export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE';
-
-// Metric data points
-export const REQUEST_METRIC_RESULT = 'REQUEST_METRIC_RESULT';
-export const RECEIVE_METRIC_RESULT_SUCCESS = 'RECEIVE_METRIC_RESULT_SUCCESS';
-export const RECEIVE_METRIC_RESULT_FAILURE = 'RECEIVE_METRIC_RESULT_FAILURE';
-
-// Parameters and other information
-export const SET_TIME_RANGE = 'SET_TIME_RANGE';
-export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS';
-export const SET_ENDPOINTS = 'SET_ENDPOINTS';
-export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
-export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE';
-export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER';
-export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS';
-export const SET_ENVIRONMENTS_FILTER = 'SET_ENVIRONMENTS_FILTER';
-export const SET_EXPANDED_PANEL = 'SET_EXPANDED_PANEL';
-
-// Panel preview
-export const REQUEST_PANEL_PREVIEW = 'REQUEST_PANEL_PREVIEW';
-export const RECEIVE_PANEL_PREVIEW_SUCCESS = 'RECEIVE_PANEL_PREVIEW_SUCCESS';
-export const RECEIVE_PANEL_PREVIEW_FAILURE = 'RECEIVE_PANEL_PREVIEW_FAILURE';
-
-export const REQUEST_PANEL_PREVIEW_METRIC_RESULT = 'REQUEST_PANEL_PREVIEW_METRIC_RESULT';
-export const RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS =
- 'RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS';
-export const RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE =
- 'RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE';
-
-export const SET_PANEL_PREVIEW_TIME_RANGE = 'SET_PANEL_PREVIEW_TIME_RANGE';
-export const SET_PANEL_PREVIEW_IS_SHOWN = 'SET_PANEL_PREVIEW_IS_SHOWN';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
deleted file mode 100644
index 5fab292b6df..00000000000
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ /dev/null
@@ -1,273 +0,0 @@
-import { pick } from 'lodash';
-import Vue from 'vue';
-import { BACKOFF_TIMEOUT } from '~/lib/utils/common_utils';
-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';
-import { optionsFromSeriesData } from './variable_mapping';
-
-/**
- * Locate and return a metric in the dashboard by its id
- * as generated by `uniqMetricsId()`.
- * @param {String} metricId Unique id in the dashboard
- * @param {Object} dashboard Full dashboard object
- */
-const findMetricInDashboard = (metricId, dashboard) => {
- let res = null;
- dashboard.panelGroups.forEach((group) => {
- group.panels.forEach((panel) => {
- panel.metrics.forEach((metric) => {
- if (metric.metricId === metricId) {
- res = metric;
- }
- });
- });
- });
- return res;
-};
-
-/**
- * Maps a backened error state to a `metricStates` constant
- * @param {Object} error - Error from backend response
- */
-const emptyStateFromError = (error) => {
- if (!error) {
- return metricStates.UNKNOWN_ERROR;
- }
-
- // Special error responses
- if (error.message === BACKOFF_TIMEOUT) {
- return metricStates.TIMEOUT;
- }
-
- // Axios error responses
- const { response } = error;
- if (response && response.status === HTTP_STATUS_SERVICE_UNAVAILABLE) {
- return metricStates.CONNECTION_FAILED;
- } else if (response && response.status === HTTP_STATUS_BAD_REQUEST) {
- // Note: "error.response.data.error" may contain Prometheus error information
- return metricStates.BAD_QUERY;
- }
-
- return metricStates.UNKNOWN_ERROR;
-};
-
-export const metricStateFromData = (data) => {
- if (data?.result?.length) {
- const result = normalizeQueryResponseData(data);
- return { state: metricStates.OK, result: Object.freeze(result) };
- }
- return { state: metricStates.NO_DATA, result: null };
-};
-
-export default {
- /**
- * Dashboard panels structure and global state
- */
- [types.REQUEST_METRICS_DASHBOARD](state) {
- state.emptyState = dashboardEmptyStates.LOADING;
- },
- [types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, dashboardYML) {
- const { dashboard, panelGroups, variables, links } = mapToDashboardViewModel(dashboardYML);
- state.dashboard = {
- dashboard,
- panelGroups,
- };
- state.variables = variables;
- state.links = links;
-
- if (!state.dashboard.panelGroups.length) {
- state.emptyState = dashboardEmptyStates.NO_DATA;
- } else {
- state.emptyState = null;
- }
- },
- [types.RECEIVE_METRICS_DASHBOARD_FAILURE](state, error) {
- state.emptyState = error
- ? dashboardEmptyStates.UNABLE_TO_CONNECT
- : dashboardEmptyStates.NO_DATA;
- },
-
- [types.REQUEST_DASHBOARD_STARRING](state) {
- state.isUpdatingStarredValue = true;
- },
- [types.RECEIVE_DASHBOARD_STARRING_SUCCESS](state, { selectedDashboard, newStarredValue }) {
- const index = state.allDashboards.findIndex((d) => d === selectedDashboard);
-
- state.isUpdatingStarredValue = false;
-
- // Trigger state updates in the reactivity system for this change
- // https://vuejs.org/v2/guide/reactivity.html#For-Arrays
- Vue.set(state.allDashboards, index, { ...selectedDashboard, starred: newStarredValue });
- },
- [types.RECEIVE_DASHBOARD_STARRING_FAILURE](state) {
- state.isUpdatingStarredValue = false;
- },
-
- [types.SET_CURRENT_DASHBOARD](state, currentDashboard) {
- state.currentDashboard = currentDashboard;
- },
-
- /**
- * Deployments and environments
- */
- [types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](state, deployments) {
- state.deploymentData = deployments;
- },
- [types.RECEIVE_DEPLOYMENTS_DATA_FAILURE](state) {
- state.deploymentData = [];
- },
- [types.REQUEST_ENVIRONMENTS_DATA](state) {
- state.environmentsLoading = true;
- },
- [types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, environments) {
- state.environmentsLoading = false;
- state.environments = environments;
- },
- [types.RECEIVE_ENVIRONMENTS_DATA_FAILURE](state) {
- state.environmentsLoading = false;
- state.environments = [];
- },
-
- /**
- * Annotations
- */
- [types.RECEIVE_ANNOTATIONS_SUCCESS](state, annotations) {
- state.annotations = annotations;
- },
- [types.RECEIVE_ANNOTATIONS_FAILURE](state) {
- state.annotations = [];
- },
-
- /**
- * Dashboard Validation Warnings
- */
- [types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS](state, hasDashboardValidationWarnings) {
- state.hasDashboardValidationWarnings = hasDashboardValidationWarnings;
- },
- [types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE](state) {
- state.hasDashboardValidationWarnings = false;
- },
-
- /**
- * Individual panel/metric results
- */
- [types.REQUEST_METRIC_RESULT](state, { metricId }) {
- const metric = findMetricInDashboard(metricId, state.dashboard);
- metric.loading = true;
- if (!metric.result) {
- metric.state = metricStates.LOADING;
- }
- },
- [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, data }) {
- const metric = findMetricInDashboard(metricId, state.dashboard);
- const metricState = metricStateFromData(data);
-
- metric.loading = false;
- metric.state = metricState.state;
- metric.result = metricState.result;
- },
- [types.RECEIVE_METRIC_RESULT_FAILURE](state, { metricId, error }) {
- const metric = findMetricInDashboard(metricId, state.dashboard);
-
- metric.state = emptyStateFromError(error);
- metric.loading = false;
- metric.result = null;
- },
-
- // Parameters and other information
- [types.SET_INITIAL_STATE](state, initialState = {}) {
- Object.assign(state, pick(initialState, initialStateKeys));
- },
- [types.SET_ENDPOINTS](state, endpoints = {}) {
- Object.assign(state, pick(endpoints, endpointKeys));
- },
- [types.SET_TIME_RANGE](state, timeRange) {
- state.timeRange = timeRange;
- },
- [types.SET_GETTING_STARTED_EMPTY_STATE](state) {
- state.emptyState = dashboardEmptyStates.GETTING_STARTED;
- },
- [types.SET_ALL_DASHBOARDS](state, dashboards) {
- state.allDashboards = dashboards || [];
- },
- [types.SET_SHOW_ERROR_BANNER](state, enabled) {
- state.showErrorBanner = enabled;
- },
- [types.SET_PANEL_GROUP_METRICS](state, payload) {
- const panelGroup = state.dashboard.panelGroups.find((pg) => payload.key === pg.key);
- panelGroup.panels = payload.panels;
- },
- [types.SET_ENVIRONMENTS_FILTER](state, searchTerm) {
- state.environmentsSearchTerm = searchTerm;
- },
- [types.SET_EXPANDED_PANEL](state, { group, panel }) {
- state.expandedPanel.group = group;
- state.expandedPanel.panel = panel;
- },
- [types.UPDATE_VARIABLE_VALUE](state, { name, value }) {
- const variable = state.variables.find((v) => v.name === name);
- if (variable) {
- Object.assign(variable, {
- value,
- });
- }
- },
- [types.UPDATE_VARIABLE_METRIC_LABEL_VALUES](state, { variable, label, data = [] }) {
- const values = optionsFromSeriesData({ label, data });
-
- // Add new options with assign to ensure Vue reactivity
- Object.assign(variable.options, { values });
- },
-
- [types.REQUEST_PANEL_PREVIEW](state, panelPreviewYml) {
- state.panelPreviewIsLoading = true;
-
- state.panelPreviewYml = panelPreviewYml;
- state.panelPreviewGraphData = null;
- state.panelPreviewError = null;
- },
- [types.RECEIVE_PANEL_PREVIEW_SUCCESS](state, payload) {
- state.panelPreviewIsLoading = false;
-
- state.panelPreviewGraphData = mapPanelToViewModel(payload);
- state.panelPreviewError = null;
- },
- [types.RECEIVE_PANEL_PREVIEW_FAILURE](state, error) {
- state.panelPreviewIsLoading = false;
-
- state.panelPreviewGraphData = null;
- state.panelPreviewError = error;
- },
-
- [types.REQUEST_PANEL_PREVIEW_METRIC_RESULT](state, { index }) {
- const metric = state.panelPreviewGraphData.metrics[index];
-
- metric.loading = true;
- if (!metric.result) {
- metric.state = metricStates.LOADING;
- }
- },
- [types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS](state, { index, data }) {
- const metric = state.panelPreviewGraphData.metrics[index];
- const metricState = metricStateFromData(data);
-
- metric.loading = false;
- metric.state = metricState.state;
- metric.result = metricState.result;
- },
- [types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE](state, { index, error }) {
- const metric = state.panelPreviewGraphData.metrics[index];
-
- metric.loading = false;
- metric.state = emptyStateFromError(error);
- metric.result = null;
- },
- [types.SET_PANEL_PREVIEW_TIME_RANGE](state, timeRange) {
- state.panelPreviewTimeRange = timeRange;
- },
- [types.SET_PANEL_PREVIEW_IS_SHOWN](state, isPreviewShown) {
- state.panelPreviewIsShown = isPreviewShown;
- },
-};
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
deleted file mode 100644
index e513b575475..00000000000
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ /dev/null
@@ -1,97 +0,0 @@
-import invalidUrl from '~/lib/utils/invalid_url';
-import { defaultTimeRange } from '~/vue_shared/constants';
-import { dashboardEmptyStates } from '../constants';
-import { timezones } from '../format_date';
-
-export default () => ({
- // API endpoints
- deploymentsEndpoint: null,
- dashboardEndpoint: invalidUrl,
- dashboardsEndpoint: invalidUrl,
- panelPreviewEndpoint: invalidUrl,
-
- // Dashboard request parameters
- timeRange: null,
- /**
- * Currently selected dashboard. For custom dashboards,
- * this could be the filename or the file path.
- *
- * If this is the filename and full path is required,
- * getters.fullDashboardPath should be used.
- */
- currentDashboard: null,
-
- // Dashboard data
- hasDashboardValidationWarnings: false,
-
- /**
- * {?String} If set, dashboard should display a global
- * empty state, there is no way to interact (yet)
- * with the dashboard.
- */
- emptyState: dashboardEmptyStates.GETTING_STARTED,
- showErrorBanner: true,
- isUpdatingStarredValue: false,
- dashboard: {
- panelGroups: [],
- },
- /**
- * Panel that is currently "zoomed" in as
- * a single panel in view.
- */
- expandedPanel: {
- /**
- * {?String} Panel's group name.
- */
- group: null,
- /**
- * {?Object} Panel content from `dashboard`
- * null when no panel is expanded.
- */
- panel: null,
- },
- allDashboards: [],
- /**
- * User-defined custom variables are passed
- * via the dashboard yml file.
- */
- variables: [],
- /**
- * User-defined custom links are passed
- * via the dashboard yml file.
- */
- links: [],
-
- // Panel editor / builder
- panelPreviewYml: '',
- panelPreviewIsLoading: false,
- panelPreviewGraphData: null,
- panelPreviewError: null,
- panelPreviewTimeRange: defaultTimeRange,
- panelPreviewIsShown: false,
-
- // Other project data
- dashboardTimezone: timezones.LOCAL,
- annotations: [],
- deploymentData: [],
- environments: [],
- environmentsSearchTerm: '',
- environmentsLoading: false,
- currentEnvironmentName: null,
-
- // GitLab paths to other pages
- externalDashboardUrl: '',
- projectPath: null,
- operationsSettingsPath: '',
- addDashboardDocumentationPath: '',
-
- // static paths
- customDashboardBasePath: '',
-
- // current user data
- /**
- * Flag that denotes if the currently logged user can access
- * the project Settings -> Operations
- */
- canAccessOperationsSettings: false,
-});
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
deleted file mode 100644
index 02a2435d575..00000000000
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ /dev/null
@@ -1,505 +0,0 @@
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import createGqClient, { fetchPolicies } from '~/lib/graphql';
-import { DATETIME_RANGE_TYPES } from '~/lib/utils/constants';
-import { timeRangeToParams, getRangeType } from '~/lib/utils/datetime_range';
-import { slugify } from '~/lib/utils/text_utility';
-import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
-import { isSafeURL, mergeUrlParams } from '~/lib/utils/url_utility';
-import { NOT_IN_DB_PREFIX, linkTypes, OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX } from '../constants';
-import { mergeURLVariables, parseTemplatingVariables } from './variable_mapping';
-
-export const gqClient = createGqClient(
- {},
- {
- fetchPolicy: fetchPolicies.NO_CACHE,
- },
-);
-
-/**
- * Metrics loaded from project-defined dashboards do not have a metricId.
- * This method creates a unique ID combining metricId and id, if either is present.
- * This is hopefully a temporary solution until BE processes metrics before passing to FE
- *
- * Related:
- * https://gitlab.com/gitlab-org/gitlab/-/issues/28241
- * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27447
- *
- * @param {Object} metric - metric
- * @param {Number} metric.metricId - Database metric id
- * @param {String} metric.id - User-defined identifier
- * @returns {Object} - normalized metric with a uniqueID
- */
-export const uniqMetricsId = ({ metricId, id }) => `${metricId || NOT_IN_DB_PREFIX}_${id}`;
-
-/**
- * Project path has a leading slash that doesn't work well
- * with project full path resolver here
- * https://gitlab.com/gitlab-org/gitlab/blob/5cad4bd721ab91305af4505b2abc92b36a56ad6b/app/graphql/resolvers/full_path_resolver.rb#L10
- *
- * @param {String} str String with leading slash
- * @returns {String}
- */
-export const removeLeadingSlash = (str) => (str || '').replace(/^\/+/, '');
-
-/**
- * GraphQL environments API returns only id and name.
- * For the environments dropdown we need metrics_path.
- * This method parses the results and add necessary attrs
- *
- * @param {Array} response Environments API result
- * @param {String} projectPath Current project path
- * @returns {Array}
- */
-export const parseEnvironmentsResponse = (response = [], projectPath) =>
- (response || []).map((env) => {
- const id = getIdFromGraphQLId(env.id);
- return {
- ...env,
- id,
- metrics_path: `${projectPath}/-/metrics?environment=${id}`,
- };
- });
-
-/**
- * Annotation API returns time in UTC. This method
- * converts time to local time.
- *
- * startingAt always exists but endingAt does not.
- * If endingAt does not exist, a threshold line is
- * drawn.
- *
- * If endingAt exists, a threshold range is drawn.
- * But this is not supported as of %12.10
- *
- * @param {Array} response annotations response
- * @returns {Array} parsed responses
- */
-export const parseAnnotationsResponse = (response) => {
- if (!response) {
- return [];
- }
- return response.map((annotation) => ({
- ...annotation,
- startingAt: new Date(annotation.startingAt),
- endingAt: annotation.endingAt ? new Date(annotation.endingAt) : null,
- }));
-};
-
-/**
- * Maps metrics to its view model
- *
- * This function difers from other in that is maps all
- * non-define properties as-is to the object. This is not
- * advisable as it could lead to unexpected side-effects.
- *
- * Related issue:
- * https://gitlab.com/gitlab-org/gitlab/issues/207198
- *
- * @param {Array} metrics - Array of prometheus metrics
- * @returns {Object}
- */
-const mapToMetricsViewModel = (metrics) =>
- metrics.map(
- ({
- label,
- id,
- metric_id: metricId,
- query_range: queryRange,
- prometheus_endpoint_path: prometheusEndpointPath,
- ...metric
- }) => ({
- label,
- queryRange,
- prometheusEndpointPath,
- metricId: uniqMetricsId({ metricId, id }),
-
- // metric data
- loading: false,
- result: null,
- state: null,
-
- ...metric,
- }),
- );
-
-/**
- * Maps X-axis view model
- *
- * @param {Object} axis
- */
-const mapXAxisToViewModel = ({ name = '' }) => ({ name });
-
-/**
- * Maps Y-axis view model
- *
- * Defaults to a 2 digit precision and `engineering` format. It only allows
- * formats in the SUPPORTED_FORMATS array.
- *
- * @param {Object} axis
- */
-const mapYAxisToViewModel = ({
- name = '',
- format = SUPPORTED_FORMATS.engineering,
- precision = 2,
-}) => {
- return {
- name,
- format: SUPPORTED_FORMATS[format] || SUPPORTED_FORMATS.engineering,
- precision,
- };
-};
-
-/**
- * Maps a link to its view model, expects an url and
- * (optionally) a title.
- *
- * Unsafe URLs are ignored.
- *
- * @param {Object} Link
- * @returns {Object} Link object with a `title`, `url` and `type`
- *
- */
-const mapLinksToViewModel = ({ url = null, title = '', type } = {}) => {
- return {
- title: title || String(url),
- type,
- url: url && isSafeURL(url) ? String(url) : '#',
- };
-};
-
-/**
- * Maps a metrics panel to its view model
- *
- * @param {Object} panel - Metrics panel
- * @returns {Object}
- */
-export const mapPanelToViewModel = ({
- id = null,
- title = '',
- type,
- x_axis: xAxisBase = {},
- x_label: xLabel,
- y_label: yLabel,
- y_axis: yAxisBase = {},
- field,
- metrics = [],
- links = [],
- min_value: minValue,
- max_value: maxValue,
- split,
- thresholds,
- format,
-}) => {
- // Both `x_axis.name` and `x_label` are supported for now
- // https://gitlab.com/gitlab-org/gitlab/issues/210521
- const xAxis = mapXAxisToViewModel({ name: xLabel, ...xAxisBase });
-
- // Both `y_axis.name` and `y_label` are supported for now
- // https://gitlab.com/gitlab-org/gitlab/issues/208385
- const yAxis = mapYAxisToViewModel({ name: yLabel, ...yAxisBase });
-
- return {
- id,
- title,
- type,
- xLabel: xAxis.name,
- y_label: yAxis.name, // Changing y_label to yLabel is pending https://gitlab.com/gitlab-org/gitlab/issues/207198
- yAxis,
- xAxis,
- field,
- minValue,
- maxValue,
- split,
- thresholds,
- format,
- links: links.map(mapLinksToViewModel),
- metrics: mapToMetricsViewModel(metrics),
- };
-};
-
-/**
- * Maps a metrics panel group to its view model
- *
- * @param {Object} panelGroup - Panel Group
- * @returns {Object}
- */
-const mapToPanelGroupViewModel = ({ group = '', panels = [] }, i) => {
- return {
- key: `${slugify(group || 'default')}-${i}`,
- group,
- panels: panels.map(mapPanelToViewModel),
- };
-};
-
-/**
- * Convert dashboard time range to Grafana
- * dashboards time range.
- *
- * @param {Object} timeRange
- * @returns {Object}
- */
-export const convertToGrafanaTimeRange = (timeRange) => {
- const timeRangeType = getRangeType(timeRange);
- if (timeRangeType === DATETIME_RANGE_TYPES.fixed) {
- return {
- from: new Date(timeRange.start).getTime(),
- to: new Date(timeRange.end).getTime(),
- };
- } else if (timeRangeType === DATETIME_RANGE_TYPES.rolling) {
- const { seconds } = timeRange.duration;
- return {
- from: `now-${seconds}s`,
- to: 'now',
- };
- }
- // fallback to returning the time range as is
- return timeRange;
-};
-
-/**
- * Convert dashboard time ranges to other supported
- * link formats.
- *
- * @param {Object} timeRange metrics dashboard time range
- * @param {String} type type of link
- * @returns {String}
- */
-export const convertTimeRanges = (timeRange, type) => {
- if (type === linkTypes.GRAFANA) {
- return convertToGrafanaTimeRange(timeRange);
- }
- return timeRangeToParams(timeRange);
-};
-
-/**
- * Adds dashboard-related metadata to the user-defined links.
- *
- * As of %13.1, metadata only includes timeRange but in the
- * future more info will be added to the links.
- *
- * @param {Object} metadata
- * @returns {Function}
- */
-export const addDashboardMetaDataToLink = (metadata) => (link) => {
- let modifiedLink = { ...link };
- if (metadata.timeRange) {
- modifiedLink = {
- ...modifiedLink,
- url: mergeUrlParams(convertTimeRanges(metadata.timeRange, link.type), link.url),
- };
- }
- return modifiedLink;
-};
-
-/**
- * Maps a dashboard json object to its view model
- *
- * @param {Object} dashboard - Dashboard object
- * @param {String} dashboard.dashboard - Dashboard name object
- * @param {Array} dashboard.panel_groups - Panel groups array
- * @returns {Object}
- */
-export const mapToDashboardViewModel = ({
- dashboard = '',
- templating = {},
- links = [],
- panel_groups: panelGroups = [],
-}) => {
- return {
- dashboard,
- variables: mergeURLVariables(parseTemplatingVariables(templating.variables)),
- links: links.map(mapLinksToViewModel),
- panelGroups: panelGroups.map(mapToPanelGroupViewModel),
- };
-};
-
-// Prometheus Results Parsing
-
-const dateTimeFromUnixTime = (unixTime) => new Date(unixTime * 1000).toISOString();
-
-const mapScalarValue = ([unixTime, value]) => [dateTimeFromUnixTime(unixTime), Number(value)];
-
-// Note: `string` value type is unused as of prometheus 2.19.
-const mapStringValue = ([unixTime, value]) => [dateTimeFromUnixTime(unixTime), value];
-
-/**
- * Processes a scalar result.
- *
- * The corresponding result property has the following format:
- *
- * [ <unix_time>, "<scalar_value>" ]
- *
- * @param {array} result
- * @returns {array}
- */
-const normalizeScalarResult = (result) => [
- {
- metric: {},
- value: mapScalarValue(result),
- values: [mapScalarValue(result)],
- },
-];
-
-/**
- * Processes a string result.
- *
- * The corresponding result property has the following format:
- *
- * [ <unix_time>, "<string_value>" ]
- *
- * Note: This value type is unused as of prometheus 2.19.
- *
- * @param {array} result
- * @returns {array}
- */
-const normalizeStringResult = (result) => [
- {
- metric: {},
- value: mapStringValue(result),
- values: [mapStringValue(result)],
- },
-];
-
-/**
- * Proccesses an instant vector.
- *
- * Instant vectors are returned as result type `vector`.
- *
- * The corresponding result property has the following format:
- *
- * [
- * {
- * "metric": { "<label_name>": "<label_value>", ... },
- * "value": [ <unix_time>, "<sample_value>" ],
- * "values": [ [ <unix_time>, "<sample_value>" ] ]
- * },
- * ...
- * ]
- *
- * `metric` - Key-value pairs object representing metric measured
- * `value` - The vector result
- * `values` - An array with a single value representing the result
- *
- * This method also adds the matrix version of the vector
- * by introducing a `values` array with a single element. This
- * allows charts to default to `values` if needed.
- *
- * @param {array} result
- * @returns {array}
- */
-const normalizeVectorResult = (result) =>
- result.map(({ metric, value }) => {
- const scalar = mapScalarValue(value);
- // Add a single element to `values`, to support matrix
- // style charts.
- return { metric, value: scalar, values: [scalar] };
- });
-
-/**
- * Range vectors are returned as result type matrix.
- *
- * The corresponding result property has the following format:
- *
- * {
- * "metric": { "<label_name>": "<label_value>", ... },
- * "value": [ <unix_time>, "<sample_value>" ],
- * "values": [ [ <unix_time>, "<sample_value>" ], ... ]
- * },
- *
- * `metric` - Key-value pairs object representing metric measured
- * `value` - The last (more recent) result
- * `values` - A range of results for the metric
- *
- * See https://prometheus.io/docs/prometheus/latest/querying/api/#range-vectors
- *
- * @param {array} result
- * @returns {object} Normalized result.
- */
-const normalizeResultMatrix = (result) =>
- result.map(({ metric, values }) => {
- const mappedValues = values.map(mapScalarValue);
- return {
- metric,
- value: mappedValues[mappedValues.length - 1],
- values: mappedValues,
- };
- });
-
-/**
- * Parse response data from a Prometheus Query that comes
- * in the format:
- *
- * {
- * "resultType": "matrix" | "vector" | "scalar" | "string",
- * "result": <value>
- * }
- *
- * @see https://prometheus.io/docs/prometheus/latest/querying/api/#expression-query-result-formats
- *
- * @param {object} data - Data containing results and result type.
- * @returns {object} - A result array of metric results:
- * [
- * {
- * metric: { ... },
- * value: ['2015-07-01T20:10:51.781Z', '1'],
- * values: [['2015-07-01T20:10:51.781Z', '1'] , ... ],
- * },
- * ...
- * ]
- *
- */
-export const normalizeQueryResponseData = (data) => {
- const { resultType, result } = data;
- if (resultType === 'vector') {
- return normalizeVectorResult(result);
- } else if (resultType === 'scalar') {
- return normalizeScalarResult(result);
- } else if (resultType === 'string') {
- return normalizeStringResult(result);
- }
- return normalizeResultMatrix(result);
-};
-
-/**
- * Custom variables defined in the dashboard yml file are
- * eventually passed over the wire to the backend Prometheus
- * API proxy.
- *
- * This method adds a prefix to the URL param keys so that
- * the backend can differential these variables from the other
- * variables.
- *
- * This is currently only used by getters/getCustomVariablesParams
- *
- * @param {String} name Variable key that needs to be prefixed
- * @returns {String}
- */
-export const addPrefixToCustomVariableParams = (name) => `variables[${name}]`;
-
-/**
- * Normalize custom dashboard paths. This method helps support
- * metrics dashboard to work with custom dashboard file names instead
- * of the entire path.
- *
- * If dashboard is empty, it is the overview dashboard.
- * If dashboard is set, it usually is a custom dashboard unless
- * explicitly it is set to overview dashboard path.
- *
- * @param {String} dashboard dashboard path
- * @param {String} dashboardPrefix custom dashboard directory prefix
- * @returns {String} normalized dashboard path
- */
-export const normalizeCustomDashboardPath = (dashboard, dashboardPrefix = '') => {
- const currDashboard = dashboard || '';
- let dashboardPath = `${dashboardPrefix}/${currDashboard}`;
-
- if (!currDashboard) {
- dashboardPath = '';
- } else if (
- currDashboard.startsWith(dashboardPrefix) ||
- currDashboard.startsWith(OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX)
- ) {
- dashboardPath = currDashboard;
- }
- return dashboardPath;
-};
diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js
deleted file mode 100644
index 4ca7a0b51d6..00000000000
--- a/app/assets/javascripts/monitoring/stores/variable_mapping.js
+++ /dev/null
@@ -1,273 +0,0 @@
-import { isString } from 'lodash';
-import { VARIABLE_TYPES } from '../constants';
-import { templatingVariablesFromUrl } from '../utils';
-
-/**
- * This file exclusively deals with parsing user-defined variables
- * in dashboard yml file.
- *
- * As of 13.0, simple text, advanced text, simple custom and
- * advanced custom variables are supported.
- *
- * In the future iterations, text and query variables will be
- * supported
- *
- */
-
-/**
- * Simple text variable is a string value only.
- * This method parses such variables to a standard format.
- *
- * @param {String|Object} simpleTextVar
- * @returns {Object}
- */
-const textSimpleVariableParser = (simpleTextVar) => ({
- type: VARIABLE_TYPES.text,
- label: null,
- value: simpleTextVar,
-});
-
-/**
- * Advanced text variable is an object.
- * This method parses such variables to a standard format.
- *
- * @param {Object} advTextVar
- * @returns {Object}
- */
-const textAdvancedVariableParser = (advTextVar) => ({
- type: VARIABLE_TYPES.text,
- label: advTextVar.label,
- value: advTextVar.options.default_value,
-});
-
-/**
- * Normalize simple and advanced custom variable options to a standard
- * format
- * @param {Object} custom variable option
- * @returns {Object} normalized custom variable options
- */
-const normalizeVariableValues = ({ default: defaultOpt = false, text, value = null }) => ({
- default: defaultOpt,
- text: text || value,
- value,
-});
-
-/**
- * Custom advanced variables are rendered as dropdown elements in the dashboard
- * header. This method parses advanced custom variables.
- *
- * The default value is the option with default set to true or the first option
- * if none of the options have default prop true.
- *
- * @param {Object} advVariable advanced custom variable
- * @returns {Object}
- */
-const customAdvancedVariableParser = (advVariable) => {
- const values = (advVariable?.options?.values ?? []).map(normalizeVariableValues);
- const defaultValue = values.find((opt) => opt.default === true) || values[0];
- return {
- type: VARIABLE_TYPES.custom,
- label: advVariable.label,
- options: {
- values,
- },
- value: defaultValue?.value || null,
- };
-};
-
-/**
- * Simple custom variables have an array of values.
- * This method parses such variables options to a standard format.
- *
- * @param {String} opt option from simple custom variable
- * @returns {Object}
- */
-export const parseSimpleCustomValues = (opt) => ({ text: opt, value: opt });
-
-/**
- * Custom simple variables are rendered as dropdown elements in the dashboard
- * header. This method parses simple custom variables.
- *
- * Simple custom variables do not have labels so its set to null here.
- *
- * The default value is set to the first option as the user cannot
- * set a default value for this format
- *
- * @param {Array} customVariable array of options
- * @returns {Object}
- */
-const customSimpleVariableParser = (simpleVar) => {
- const values = (simpleVar || []).map(parseSimpleCustomValues);
- return {
- type: VARIABLE_TYPES.custom,
- label: null,
- value: values[0].value || null,
- options: {
- values: values.map(normalizeVariableValues),
- },
- };
-};
-
-const metricLabelValuesVariableParser = ({ label, options = {} }) => ({
- type: VARIABLE_TYPES.metric_label_values,
- label,
- value: null,
- options: {
- prometheusEndpointPath: options.prometheus_endpoint_path || '',
- label: options.label || null,
- values: [], // values are initially empty
- },
-});
-
-/**
- * Utility method to determine if a custom variable is
- * simple or not. If its not simple, it is advanced.
- *
- * @param {Array|Object} customVar Array if simple, object if advanced
- * @returns {Boolean} true if simple, false if advanced
- */
-const isSimpleCustomVariable = (customVar) => Array.isArray(customVar);
-
-/**
- * This method returns a parser based on the type of the variable.
- * Currently, the supported variables are simple custom and
- * advanced custom only. In the future, this method will support
- * text and query variables.
- *
- * @param {Array|Object} variable
- * @return {Function} parser method
- */
-const getVariableParser = (variable) => {
- if (isString(variable)) {
- return textSimpleVariableParser;
- } else if (isSimpleCustomVariable(variable)) {
- return customSimpleVariableParser;
- } else if (variable.type === VARIABLE_TYPES.text) {
- return textAdvancedVariableParser;
- } else if (variable.type === VARIABLE_TYPES.custom) {
- return customAdvancedVariableParser;
- } else if (variable.type === VARIABLE_TYPES.metric_label_values) {
- return metricLabelValuesVariableParser;
- }
- return () => null;
-};
-
-/**
- * This method parses the templating property in the dashboard yml file.
- * The templating property has variables that are rendered as input elements
- * for the user to edit. The values from input elements are relayed to
- * backend and eventually Prometheus API.
- *
- * @param {Object} templating variables from the dashboard yml file
- * @returns {array} An array of variables to display as inputs
- */
-export const parseTemplatingVariables = (ymlVariables = {}) =>
- Object.entries(ymlVariables).reduce((acc, [name, ymlVariable]) => {
- // get the parser
- const parser = getVariableParser(ymlVariable);
- // parse the variable
- const variable = parser(ymlVariable);
- // for simple custom variable label is null and it should be
- // replace with key instead
- if (variable) {
- acc.push({
- ...variable,
- name,
- label: variable.label || name,
- });
- }
- return acc;
- }, []);
-
-/**
- * Custom variables are defined in the dashboard yml file
- * and their values can be passed through the URL.
- *
- * On component load, this method merges variables data
- * from the yml file with URL data to store in the Vuex store.
- * Not all params coming from the URL need to be stored. Only
- * the ones that have a corresponding variable defined in the
- * yml file.
- *
- * This ensures that there is always a single source of truth
- * for variables
- *
- * This method can be improved further. See the below issue
- * https://gitlab.com/gitlab-org/gitlab/-/issues/217713
- *
- * @param {array} parsedYmlVariables - template variables from yml file
- * @returns {Object}
- */
-export const mergeURLVariables = (parsedYmlVariables = []) => {
- const varsFromURL = templatingVariablesFromUrl();
- parsedYmlVariables.forEach((variable) => {
- const { name } = variable;
- if (Object.prototype.hasOwnProperty.call(varsFromURL, name)) {
- Object.assign(variable, { value: varsFromURL[name] });
- }
- });
- return parsedYmlVariables;
-};
-
-/**
- * Converts series data to options that can be added to a
- * variable. Series data is returned from the Prometheus API
- * `/api/v1/series`.
- *
- * Finds a `label` in the series data, so it can be used as
- * a filter.
- *
- * For example, for the arguments:
- *
- * {
- * "label": "job"
- * "data" : [
- * {
- * "__name__" : "up",
- * "job" : "prometheus",
- * "instance" : "localhost:9090"
- * },
- * {
- * "__name__" : "up",
- * "job" : "node",
- * "instance" : "localhost:9091"
- * },
- * {
- * "__name__" : "process_start_time_seconds",
- * "job" : "prometheus",
- * "instance" : "localhost:9090"
- * }
- * ]
- * }
- *
- * It returns all the different "job" values:
- *
- * [
- * {
- * "label": "node",
- * "value": "node"
- * },
- * {
- * "label": "prometheus",
- * "value": "prometheus"
- * }
- * ]
- *
- * @param {options} options object
- * @param {options.seriesLabel} name of the searched series label
- * @param {options.data} series data from the series API
- * @return {array} Options objects with the shape `{ label, value }`
- *
- * @see https://prometheus.io/docs/prometheus/latest/querying/api/#finding-series-by-label-matchers
- */
-export const optionsFromSeriesData = ({ label, data = [] }) => {
- const optionsSet = data.reduce((set, seriesObject) => {
- // Use `new Set` to deduplicate options
- if (seriesObject[label]) {
- set.add(seriesObject[label]);
- }
- return set;
- }, new Set());
-
- return [...optionsSet].map(parseSimpleCustomValues);
-};
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
deleted file mode 100644
index 5f4d2703d21..00000000000
--- a/app/assets/javascripts/monitoring/utils.js
+++ /dev/null
@@ -1,402 +0,0 @@
-import { pickBy, mapKeys } from 'lodash';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import {
- timeRangeParamNames,
- timeRangeFromParams,
- timeRangeToParams,
-} from '~/lib/utils/datetime_range';
-import {
- queryToObject,
- mergeUrlParams,
- removeParams,
- updateHistory,
-} from '~/lib/utils/url_utility';
-import { VARIABLE_PREFIX } from './constants';
-
-/**
- * Extracts the initial state and props from HTML dataset
- * and places them in separate objects to setup bundle.
- * @param {*} dataset
- */
-export const stateAndPropsFromDataset = (dataset = {}) => {
- const {
- currentDashboard,
- deploymentsEndpoint,
- dashboardEndpoint,
- dashboardsEndpoint,
- panelPreviewEndpoint,
- dashboardTimezone,
- canAccessOperationsSettings,
- operationsSettingsPath,
- projectPath,
- externalDashboardUrl,
- currentEnvironmentName,
- customDashboardBasePath,
- addDashboardDocumentationPath,
- ...dataProps
- } = dataset;
-
- // HTML attributes are always strings, parse other types.
- dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics);
- dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable);
-
- return {
- initState: {
- currentDashboard,
- deploymentsEndpoint,
- dashboardEndpoint,
- dashboardsEndpoint,
- panelPreviewEndpoint,
- dashboardTimezone,
- canAccessOperationsSettings,
- operationsSettingsPath,
- projectPath,
- externalDashboardUrl,
- currentEnvironmentName,
- customDashboardBasePath,
- addDashboardDocumentationPath,
- },
- dataProps,
- };
-};
-
-/**
- * List of non time range url parameters
- * This will be removed once we add support for free text variables
- * via the dashboard yaml files in https://gitlab.com/gitlab-org/gitlab/-/issues/215689
- */
-export const dashboardParams = ['dashboard', 'group', 'title', 'y_label', 'embedded'];
-
-/**
- * This method is used to validate if the graph data format for a chart component
- * that needs a time series as a response from a prometheus query (queryRange) is
- * of a valid format or not.
- * @param {Object} graphData the graph data response from a prometheus request
- * @returns {boolean} whether the graphData format is correct
- */
-export const graphDataValidatorForValues = (isValues, graphData) => {
- const responseValueKeyName = isValues ? 'value' : 'values';
- return (
- Array.isArray(graphData.metrics) &&
- graphData.metrics.filter((query) => {
- if (Array.isArray(query.result)) {
- return (
- query.result.filter((res) => Array.isArray(res[responseValueKeyName])).length ===
- query.result.length
- );
- }
- return false;
- }).length === graphData.metrics.filter((query) => query.result).length
- );
-};
-
-/**
- * Checks that element that triggered event is located on cluster health check dashboard
- * @param {HTMLElement} element to check against
- * @returns {boolean}
- */
-const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show');
-
-/**
- * Tracks snowplow event when user generates link to metric chart
- * @param {String} chart link that will be sent as a property for the event
- * @return {Object} config object for event tracking
- */
-export const generateLinkToChartOptions = (chartLink) => {
- const isCLusterHealthBoard = isClusterHealthBoard();
-
- const category = isCLusterHealthBoard
- ? 'Cluster Monitoring' // eslint-disable-line @gitlab/require-i18n-strings
- : 'Incident Management::Embedded metrics';
- const action = isCLusterHealthBoard
- ? 'generate_link_to_cluster_metric_chart'
- : 'generate_link_to_metrics_chart';
-
- return { category, action, label: 'Chart link', property: chartLink }; // eslint-disable-line @gitlab/require-i18n-strings
-};
-
-/**
- * Tracks snowplow event when user downloads CSV of cluster metric
- * @param {String} chart title that will be sent as a property for the event
- * @return {Object} config object for event tracking
- */
-export const downloadCSVOptions = (title) => {
- const isCLusterHealthBoard = isClusterHealthBoard();
-
- const category = isCLusterHealthBoard
- ? 'Cluster Monitoring' // eslint-disable-line @gitlab/require-i18n-strings
- : 'Incident Management::Embedded metrics';
- const action = isCLusterHealthBoard
- ? 'download_csv_of_cluster_metric_chart'
- : 'download_csv_of_metrics_dashboard_chart';
-
- return { category, action, label: 'Chart title', property: title }; // eslint-disable-line @gitlab/require-i18n-strings
-};
-/* eslint-enable @gitlab/require-i18n-strings */
-
-/**
- * Generate options for snowplow to track adding a new metric via the dashboard
- * custom metric modal
- * @return {Object} config object for event tracking
- */
-export const getAddMetricTrackingOptions = () => ({
- category: document.body.dataset.page,
- action: 'click_button',
- label: 'add_new_metric',
- property: 'modal',
-});
-
-/**
- * This function validates the graph data contains exactly 3 metrics plus
- * value validations from graphDataValidatorForValues.
- * @param {Object} isValues
- * @param {Object} graphData the graph data response from a prometheus request
- * @returns {boolean} true if the data is valid
- */
-export const graphDataValidatorForAnomalyValues = (graphData) => {
- const anomalySeriesCount = 3; // metric, upper, lower
- return (
- graphData.metrics &&
- graphData.metrics.length === anomalySeriesCount &&
- graphDataValidatorForValues(false, graphData)
- );
-};
-
-/**
- * Returns a time range from the current URL params
- *
- * @returns {Object|null} The time range defined by the
- * current URL, reading from search query or `window.location.search`.
- * Returns `null` if no parameters form a time range.
- */
-export const timeRangeFromUrl = (search = window.location.search) => {
- const params = queryToObject(search, { legacySpacesDecode: true });
- return timeRangeFromParams(params);
-};
-
-/**
- * Variable labels are used as names for the dropdowns and also
- * as URL params. Prefixing the name reduces the risk of
- * collision with other URL params
- *
- * @param {String} label label for the template variable
- * @returns {String}
- */
-export const addPrefixToLabel = (label) => `${VARIABLE_PREFIX}${label}`;
-
-/**
- * Before the templating variables are passed to the backend the
- * prefix needs to be removed.
- *
- * This method removes the prefix at the beginning of the string.
- *
- * @param {String} label label to remove prefix from
- * @returns {String}
- */
-export const removePrefixFromLabel = (label) =>
- (label || '').replace(new RegExp(`^${VARIABLE_PREFIX}`), '');
-
-/**
- * Convert parsed template variables to an object
- * with just keys and values. Prepare the variables
- * to be added to the URL. Keys of the object will
- * have a prefix so that these params can be
- * differentiated from other URL params.
- *
- * @param {Object} variables
- * @returns {Object}
- */
-export const convertVariablesForURL = (variables) =>
- variables.reduce((acc, { name, value }) => {
- if (value !== null) {
- acc[addPrefixToLabel(name)] = value;
- }
- return acc;
- }, {});
-
-/**
- * User-defined variables from the URL are extracted. The variables
- * begin with a constant prefix so that it doesn't collide with
- * other URL params.
- *
- * @param {String} search URL
- * @returns {Object} The custom variables defined by the user in the URL
- */
-export const templatingVariablesFromUrl = (search = window.location.search) => {
- const params = queryToObject(search, { legacySpacesDecode: true });
- // pick the params with variable prefix
- const paramsWithVars = pickBy(params, (val, key) => key.startsWith(VARIABLE_PREFIX));
- // remove the prefix before storing in the Vuex store
- return mapKeys(paramsWithVars, (val, key) => removePrefixFromLabel(key));
-};
-
-/**
- * Update the URL with variables. This usually get triggered when
- * the user interacts with the dynamic input elements in the monitoring
- * dashboard header.
- *
- * @param {Object} variables user defined variables
- */
-export const setCustomVariablesFromUrl = (variables) => {
- // prep the variables to append to URL
- const parsedVariables = convertVariablesForURL(variables);
- // update the URL
- updateHistory({
- url: mergeUrlParams(parsedVariables, window.location.href),
- title: document.title,
- });
-};
-
-/**
- * Returns a URL with no time range based on the current URL.
- *
- * @param {String} New URL
- */
-export const removeTimeRangeParams = (url = window.location.href) =>
- removeParams(timeRangeParamNames, url);
-
-/**
- * Returns a URL for the a different time range based on the
- * current URL and a time range.
- *
- * @param {String} New URL
- */
-export const timeRangeToUrl = (timeRange, url = window.location.href) => {
- const toUrl = removeTimeRangeParams(url);
- const params = timeRangeToParams(timeRange);
- return mergeUrlParams(params, toUrl);
-};
-
-/**
- * Locates a panel (and its corresponding group) given a (URL) search query. Returns
- * it as payload for the store to set the right expandaded panel.
- *
- * Params used to locate a panel are:
- * - group: Group identifier
- * - title: Panel title
- * - y_label: Panel y_label
- *
- * @param {Object} dashboard - Dashboard reference from the Vuex store
- * @param {String} search - URL location search query
- * @returns {Object} payload - Payload for expanded panel to be displayed
- * @returns {String} payload.group - Group where panel is located
- * @returns {Object} payload.panel - Dashboard panel (graphData) reference
- * @throws Will throw an error if Panel cannot be located.
- */
-export const expandedPanelPayloadFromUrl = (dashboard, search = window.location.search) => {
- const params = queryToObject(search, { legacySpacesDecode: true });
-
- // Search for the panel if any of the search params is identified
- if (params.group || params.title || params.y_label) {
- const panelGroup = dashboard.panelGroups.find(({ group }) => params.group === group);
- const panel = panelGroup.panels.find(
- // eslint-disable-next-line camelcase
- ({ y_label, title }) => y_label === params.y_label && title === params.title,
- );
-
- if (!panel) {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- throw new Error('Panel could no found by URL parameters.');
- }
- return { group: panelGroup.group, panel };
- }
- return null;
-};
-
-/**
- * Convert panel information to a URL for the user to
- * bookmark or share highlighting a specific panel.
- *
- * If no group/panel is set, the dashboard URL is returned.
- *
- * @param {?String} dashboard - Dashboard path, used as identifier for a dashboard
- * @param {?Object} variables - Custom variables that came from the URL
- * @param {?String} group - Group Identifier
- * @param {?Object} panel - Panel object from the dashboard
- * @param {?String} url - Base URL including current search params
- * @returns Dashboard URL which expands a panel (chart)
- */
-export const panelToUrl = (
- dashboard = null,
- variables,
- group,
- panel,
- url = window.location.href,
-) => {
- const params = {
- dashboard,
- ...variables,
- };
-
- if (group && panel) {
- params.group = group;
- params.title = panel.title;
- params.y_label = panel.y_label;
- } else {
- // Remove existing parameters if any
- params.group = null;
- params.title = null;
- params.y_label = null;
- }
-
- return mergeUrlParams(params, url);
-};
-
-/**
- * Get the metric value from first data point.
- * Currently only used for bar charts
- *
- * @param {Array} values data points
- * @returns {Number}
- */
-const metricValueMapper = (values) => values[0]?.[1];
-
-/**
- * Get the metric name from metric object
- * Currently only used for bar charts
- * e.g. { handler: '/query' }
- * { method: 'get' }
- *
- * @param {Object} metric metric object
- * @returns {String}
- */
-const metricNameMapper = (metric) => Object.values(metric)?.[0];
-
-/**
- * Parse metric object to extract metric value and name in
- * [<metric-value>, <metric-name>] format.
- * Currently only used for bar charts
- *
- * @param {Object} param0 metric object
- * @returns {Array}
- */
-const resultMapper = ({ metric, values = [] }) => [
- metricValueMapper(values),
- metricNameMapper(metric),
-];
-
-/**
- * Bar charts graph data parser to massage data from
- * backend to a format acceptable by bar charts component
- * in GitLab UI
- *
- * e.g.
- * {
- * SLO: [
- * [98, 'api'],
- * [99, 'web'],
- * [99, 'database']
- * ]
- * }
- *
- * @param {Array} data series information
- * @returns {Object}
- */
-export const barChartsDataParser = (data = []) =>
- data?.reduce(
- (acc, { result = [], label }) => ({
- ...acc,
- [label]: result.map(resultMapper),
- }),
- {},
- );
diff --git a/app/assets/javascripts/monitoring/validators.js b/app/assets/javascripts/monitoring/validators.js
deleted file mode 100644
index 05a9d8b9db5..00000000000
--- a/app/assets/javascripts/monitoring/validators.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import { isSafeURL } from '~/lib/utils/url_utility';
-
-const isRunbookUrlValid = (runbookUrl) => {
- if (!runbookUrl) {
- return true;
- }
- return isSafeURL(runbookUrl);
-};
-
-// Prop validator for alert information, expecting an object like the example below.
-//
-// {
-// '/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37': {
-// alert_path: "/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37",
-// metricId: '1',
-// operator: ">",
-// query: "rate(http_requests_total[5m])[30m:1m]",
-// threshold: 0.002,
-// title: "Core Usage (Total)",
-// runbookUrl: "https://www.gitlab.com/my-project/-/wikis/runbook"
-// }
-// }
-export function alertsValidator(value) {
- return Object.keys(value).every((key) => {
- const alert = value[key];
- return (
- alert.alert_path &&
- key === alert.alert_path &&
- alert.metricId &&
- typeof alert.metricId === 'string' &&
- alert.operator &&
- typeof alert.threshold === 'number' &&
- isRunbookUrlValid(alert.runbookUrl)
- );
- });
-}
-
-// Prop validator for query information, expecting an array like the example below.
-//
-// [
-// {
-// metricId: '16',
-// label: 'Total Cores'
-// },
-// {
-// metricId: '17',
-// label: 'Sub-total Cores'
-// }
-// ]
-export function queriesValidator(value) {
- return value.every(
- (query) =>
- query.metricId && typeof query.metricId === 'string' && typeof query.label === 'string',
- );
-}
diff --git a/app/assets/javascripts/mr_more_dropdown.js b/app/assets/javascripts/mr_more_dropdown.js
index 720619b72ae..4a9e10be5ad 100644
--- a/app/assets/javascripts/mr_more_dropdown.js
+++ b/app/assets/javascripts/mr_more_dropdown.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import { initReportAbuse } from '~/projects/report_abuse';
import MrMoreDropdown from '~/vue_shared/components/mr_more_dropdown.vue';
export const initMrMoreDropdown = () => {
@@ -11,6 +12,7 @@ export const initMrMoreDropdown = () => {
const {
mergeRequest,
projectPath,
+ url,
editUrl,
isCurrentUser,
isLoggedIn,
@@ -20,7 +22,6 @@ export const initMrMoreDropdown = () => {
sourceProjectMissing,
clipboardText,
reportedUserId,
- reportedFromUrl,
} = el.dataset;
let mr;
@@ -35,12 +36,17 @@ export const initMrMoreDropdown = () => {
el,
provide: {
reportAbusePath: el.dataset.reportAbusePath,
+ showSummaryNotesToggle: Boolean(document.querySelector('#js-summary-notes')),
+ },
+ beforeCreate() {
+ initReportAbuse();
},
render: (createElement) =>
createElement(MrMoreDropdown, {
props: {
mr,
projectPath,
+ url,
editUrl,
isCurrentUser,
isLoggedIn: Boolean(isLoggedIn),
@@ -50,7 +56,6 @@ export const initMrMoreDropdown = () => {
sourceProjectMissing,
clipboardText,
reportedUserId: Number(reportedUserId),
- reportedFromUrl,
},
}),
});
diff --git a/app/assets/javascripts/mr_notes/init.js b/app/assets/javascripts/mr_notes/init.js
index e8e3376cee2..28f294589ae 100644
--- a/app/assets/javascripts/mr_notes/init.js
+++ b/app/assets/javascripts/mr_notes/init.js
@@ -1,12 +1,13 @@
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { parseBoolean, getCookie } from '~/lib/utils/common_utils';
import mrNotes from '~/mr_notes/stores';
-import { getLocationHash } from '~/lib/utils/url_utility';
+import { getLocationHash, getParameterValues } from '~/lib/utils/url_utility';
import eventHub from '~/notes/event_hub';
import { initReviewBar } from '~/batch_comments';
import { initDiscussionCounter } from '~/mr_notes/discussion_counter';
import { initOverviewTabCounter } from '~/mr_notes/init_count';
import { getDerivedMergeRequestInformation } from '~/diffs/utils/merge_request';
import { getReviewsForMergeRequest } from '~/diffs/utils/file_reviews';
+import { DIFF_VIEW_COOKIE_NAME, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constants';
function setupMrNotesState(store, notesDataset, diffsDataset) {
const noteableData = JSON.parse(notesDataset.noteableData);
@@ -37,6 +38,8 @@ function setupMrNotesState(store, notesDataset, diffsDataset) {
viewDiffsFileByFile: parseBoolean(diffsDataset.fileByFileDefault),
defaultSuggestionCommitMessage: diffsDataset.defaultSuggestionCommitMessage,
mrReviews: getReviewsForMergeRequest(mrPath),
+ diffViewType:
+ getParameterValues('view')[0] || getCookie(DIFF_VIEW_COOKIE_NAME) || INLINE_DIFF_VIEW_TYPE,
});
}
diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue
index 9db123da405..c36c56d7e43 100644
--- a/app/assets/javascripts/nav/components/new_nav_toggle.vue
+++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue
@@ -71,7 +71,12 @@ export default {
class="gl-new-dropdown-item-text-wrapper gl-display-flex! gl-justify-content-space-between gl-align-items-center gl-py-2!"
>
{{ $options.i18n.toggleMenuItemLabel }}
- <gl-toggle :value="isEnabled" :label="$options.i18n.toggleLabel" label-position="hidden" />
+ <gl-toggle
+ :value="isEnabled"
+ :label="$options.i18n.toggleLabel"
+ label-position="hidden"
+ data-testid="new-navigation-toggle"
+ />
</div>
</div>
</gl-disclosure-dropdown-item>
@@ -92,7 +97,7 @@ export default {
:value="isEnabled"
:label="$options.i18n.toggleLabel"
label-position="hidden"
- data-qa-selector="new_navigation_toggle"
+ data-testid="new_navigation_toggle"
/>
</div>
</li>
diff --git a/app/assets/javascripts/nav/components/responsive_home.vue b/app/assets/javascripts/nav/components/responsive_home.vue
index a80fda96363..371b252a6ba 100644
--- a/app/assets/javascripts/nav/components/responsive_home.vue
+++ b/app/assets/javascripts/nav/components/responsive_home.vue
@@ -55,7 +55,7 @@ export default {
v-gl-tooltip="{ title: newDropdownViewModel.title }"
:view-model="newDropdownViewModel"
class="gl-ml-3"
- data-qa-selector="mobile_new_dropdown"
+ data-testid="mobile_new_dropdown"
/>
</header>
<top-nav-menu-sections class="gl-h-full" :sections="menuSections" v-on="$listeners" />
diff --git a/app/assets/javascripts/nav/components/top_nav_app.vue b/app/assets/javascripts/nav/components/top_nav_app.vue
index ab9313f7041..22c77e9ae32 100644
--- a/app/assets/javascripts/nav/components/top_nav_app.vue
+++ b/app/assets/javascripts/nav/components/top_nav_app.vue
@@ -35,7 +35,7 @@ export default {
<gl-nav class="navbar-sub-nav">
<gl-nav-item-dropdown
v-gl-tooltip.bottom="navData.menuTooltip"
- data-qa-selector="navbar_dropdown"
+ data-testid="navbar_dropdown"
data-qa-title="Menu"
menu-class="gl-mt-3! gl-max-w-none! gl-max-h-none! gl-sm-w-auto! js-top-nav-dropdown-menu"
toggle-class="top-nav-toggle js-top-nav-dropdown-toggle gl-px-3!"
diff --git a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue
index 0f069670d09..fa202a0574d 100644
--- a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue
+++ b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue
@@ -87,7 +87,6 @@ export default {
:slot-key="activeView"
class="gl-w-grid-size-40 gl-overflow-hidden gl-p-3"
data-testid="menu-subview"
- data-qa-selector="menu_subview_container"
>
<template #projects>
<top-nav-container-view
diff --git a/app/assets/javascripts/notes/components/comment_field_layout.vue b/app/assets/javascripts/notes/components/comment_field_layout.vue
index bde7d219e9f..cefcc1b0c98 100644
--- a/app/assets/javascripts/notes/components/comment_field_layout.vue
+++ b/app/assets/javascripts/notes/components/comment_field_layout.vue
@@ -66,9 +66,7 @@ export default {
};
</script>
<template>
- <div
- class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-lg gl-border-gray-100 gl-bg-white gl-overflow-hidden"
- >
+ <div class="comment-warning-wrapper">
<div
v-if="withAlertContainer"
class="error-alert"
@@ -76,7 +74,7 @@ export default {
></div>
<noteable-warning
v-if="hasWarning"
- class="gl-py-4 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100 gl-rounded-base gl-rounded-bottom-left-none gl-rounded-bottom-right-none"
+ class="gl-pt-4 gl-pb-5 gl-mb-n3 gl-rounded-lg gl-rounded-bottom-left-none gl-rounded-bottom-right-none"
:is-locked="isLocked"
:is-confidential="isConfidential"
:noteable-type="noteableType"
@@ -84,10 +82,20 @@ export default {
:confidential-noteable-docs-path="noteableData.confidential_issues_docs_path"
/>
<slot></slot>
- <attachments-warning v-if="showAttachmentWarning" />
+ <attachments-warning
+ v-if="showAttachmentWarning"
+ :class="{
+ 'gl-py-3': !showEmailParticipantsWarning,
+ 'gl-pt-4 gl-pb-3 gl-mt-n3': showEmailParticipantsWarning,
+ }"
+ />
<email-participants-warning
v-if="showEmailParticipantsWarning"
- class="gl-border-t-1 gl-border-t-solid gl-border-t-gray-100 gl-rounded-base gl-rounded-top-left-none! gl-rounded-top-right-none!"
+ class="gl-border-t-1 gl-rounded-lg gl-rounded-top-left-none! gl-rounded-top-right-none!"
+ :class="{
+ 'gl-pt-4 gl-pb-3 gl-mt-n3': !showAttachmentWarning,
+ 'gl-py-3 gl-mt-1': showAttachmentWarning,
+ }"
:emails="emailParticipants"
/>
</div>
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index cba0f960c00..c6d94a3b7b7 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -16,11 +16,12 @@ import { sprintf } from '~/locale';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking';
import * as constants from '../constants';
import eventHub from '../event_hub';
import { COMMENT_FORM } from '../i18n';
-import { getErrorMessages } from '../utils';
+import { createNoteErrorMessages } from '../utils';
import issuableStateMixin from '../mixins/issuable_state';
import CommentFieldLayout from './comment_field_layout.vue';
@@ -146,9 +147,6 @@ export default {
markdownDocsPath() {
return this.getNotesData.markdownDocsPath;
},
- quickActionsDocsPath() {
- return this.getNotesData.quickActionsDocsPath;
- },
markdownPreviewPath() {
return this.getNoteableData.preview_note_path;
},
@@ -219,7 +217,7 @@ export default {
'toggleIssueLocalState',
]),
handleSaveError({ data, status }) {
- this.errors = getErrorMessages(data, status);
+ this.errors = createNoteErrorMessages(data, status);
},
handleSaveDraft() {
this.handleSave({ isDraft: true });
@@ -258,6 +256,11 @@ export default {
this.isSubmitting = true;
+ trackSavedUsingEditor(
+ this.$refs.markdownEditor.isContentEditorActive,
+ `${this.noteableType}_${this.noteType}`,
+ );
+
this.saveNote(noteData)
.then(() => {
this.restartPolling();
@@ -366,7 +369,6 @@ export default {
:render-markdown-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:add-spacing-classes="false"
- :quick-actions-docs-path="quickActionsDocsPath"
:form-field-props="formFieldProps"
:autosave-key="autosaveKey"
:disabled="isSubmitting"
diff --git a/app/assets/javascripts/notes/components/comment_type_dropdown.vue b/app/assets/javascripts/notes/components/comment_type_dropdown.vue
index 543be838920..2e4f925194f 100644
--- a/app/assets/javascripts/notes/components/comment_type_dropdown.vue
+++ b/app/assets/javascripts/notes/components/comment_type_dropdown.vue
@@ -1,16 +1,20 @@
<script>
-import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
+import { GlButtonGroup, GlButton, GlCollapsibleListbox } from '@gitlab/ui';
-import { sprintf } from '~/locale';
+import { sprintf, __ } from '~/locale';
import { COMMENT_FORM } from '~/notes/i18n';
import * as constants from '../constants';
export default {
- i18n: COMMENT_FORM,
+ name: 'CommentTypeDropdown',
+ i18n: {
+ ...COMMENT_FORM,
+ toggleSrText: __('Comment type'),
+ },
components: {
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
+ GlButtonGroup,
+ GlButton,
+ GlCollapsibleListbox,
},
model: {
prop: 'noteType',
@@ -93,56 +97,63 @@ export default {
noteableDisplayName: this.noteableDisplayName,
});
},
+ dropdownItems() {
+ return [
+ {
+ text: this.dropdownCommentButtonTitle,
+ description: this.commentDescription,
+ value: constants.COMMENT,
+ },
+ {
+ text: this.dropdownStartThreadButtonTitle,
+ description: this.startDiscussionDescription,
+ value: constants.DISCUSSION,
+ qaSelector: 'discussion_menu_item',
+ },
+ ];
+ },
},
methods: {
handleClick() {
this.$emit('click');
},
- setNoteTypeToComment() {
- if (this.noteType !== constants.COMMENT) {
- this.$emit('change', constants.COMMENT);
- }
- },
- setNoteTypeToDiscussion() {
- if (this.noteType !== constants.DISCUSSION) {
- this.$emit('change', constants.DISCUSSION);
- }
+ setNoteType(value) {
+ this.$emit('change', value);
},
},
};
</script>
<template>
- <gl-dropdown
- split
- :text="commentButtonTitle"
- class="gl-mr-3 js-comment-button js-comment-submit-button comment-type-dropdown"
- category="primary"
- variant="confirm"
- :disabled="disabled"
- data-testid="comment-button"
- data-qa-selector="comment_button"
+ <!--TODO: Replace button-group workaround once `split` option for new dropdowns is implemented.-->
+ <!-- See issue at https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2263-->
+ <gl-button-group
+ class="js-comment-button js-comment-submit-button comment-type-dropdown gl-w-full gl-mb-3 gl-md-w-auto gl-md-mb-0"
:data-track-label="trackingLabel"
data-track-action="click_button"
- @click="$emit('click')"
+ data-testid="comment-button"
+ data-qa-selector="comment_button"
>
- <gl-dropdown-item
- is-check-item
- :is-checked="isNoteTypeComment"
- @click.stop.prevent="setNoteTypeToComment"
- >
- <strong>{{ dropdownCommentButtonTitle }}</strong>
- <p class="gl-m-0">{{ commentDescription }}</p>
- </gl-dropdown-item>
- <gl-dropdown-divider />
- <gl-dropdown-item
- is-check-item
- :is-checked="isNoteTypeDiscussion"
- data-qa-selector="discussion_menu_item"
- @click.stop.prevent="setNoteTypeToDiscussion"
+ <gl-button variant="confirm" :disabled="disabled" @click="handleClick">
+ {{ commentButtonTitle }}
+ </gl-button>
+ <gl-collapsible-listbox
+ class="split"
+ toggle-class="gl-rounded-top-left-none! gl-rounded-bottom-left-none! gl-pl-1!"
+ variant="confirm"
+ text-sr-only
+ :toggle-text="$options.i18n.toggleSrText"
+ :disabled="disabled"
+ :items="dropdownItems"
+ :selected="noteType"
+ @select="setNoteType"
>
- <strong>{{ dropdownStartThreadButtonTitle }}</strong>
- <p class="gl-m-0">{{ startDiscussionDescription }}</p>
- </gl-dropdown-item>
- </gl-dropdown>
+ <template #list-item="{ item }">
+ <div :data-qa-selector="item.qaSelector">
+ <strong>{{ item.text }}</strong>
+ <p class="gl-m-0">{{ item.description }}</p>
+ </div>
+ </template>
+ </gl-collapsible-listbox>
+ </gl-button-group>
</template>
diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue
index c53d3203327..e7b7ba7743e 100644
--- a/app/assets/javascripts/notes/components/diff_discussion_header.vue
+++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue
@@ -107,7 +107,13 @@ export default {
<template>
<div class="discussion-header gl-display-flex gl-align-items-center">
<div v-once class="timeline-avatar gl-align-self-start gl-flex-shrink-0 gl-flex-shrink">
- <gl-avatar-link v-if="author" :href="author.path">
+ <gl-avatar-link
+ v-if="author"
+ :href="author.path"
+ :data-user-id="author.id"
+ :data-username="author.username"
+ class="js-user-link"
+ >
<gl-avatar :src="author.avatar_url" :alt="author.name" :size="32" />
</gl-avatar-link>
</div>
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index ba5ffc60917..cff1043c258 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -1,13 +1,6 @@
<script>
-import {
- GlTooltipDirective,
- GlButton,
- GlButtonGroup,
- GlDropdown,
- GlDropdownItem,
- GlIcon,
-} from '@gitlab/ui';
-import { mapGetters, mapActions } from 'vuex';
+import { GlButton, GlButtonGroup, GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui';
+import { mapActions, mapGetters } from 'vuex';
import { throttle } from 'lodash';
import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -18,11 +11,9 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: {
+ GlDisclosureDropdown,
GlButton,
GlButtonGroup,
- GlDropdown,
- GlDropdownItem,
- GlIcon,
},
mixins: [glFeatureFlagsMixin(), discussionNavigation],
props: {
@@ -56,6 +47,29 @@ export default {
resolveAllDiscussionsIssuePath() {
return this.getNoteableData.create_issue_to_resolve_discussions_path;
},
+ threadOptions() {
+ const options = [
+ {
+ text: this.toggleThreadsLabel,
+ action: this.handleExpandDiscussions,
+ extraAttrs: {
+ 'data-testid': 'toggle-all-discussions-btn',
+ },
+ },
+ ];
+
+ if (this.resolveAllDiscussionsIssuePath && !this.allResolved) {
+ options.push({
+ text: __('Resolve all with new issue'),
+ href: this.resolveAllDiscussionsIssuePath,
+ extraAttrs: {
+ 'data-testid': 'resolve-all-with-issue-link',
+ },
+ });
+ }
+
+ return options;
+ },
},
methods: {
...mapActions(['setExpandDiscussions']),
@@ -86,32 +100,25 @@ export default {
>
<template v-if="allResolved">
{{ __('All threads resolved!') }}
- <gl-dropdown
- v-gl-tooltip:discussionCounter.hover.bottom
+ <gl-disclosure-dropdown
+ v-gl-tooltip:discussionCounter.hover.top
+ icon="ellipsis_v"
size="small"
category="tertiary"
- right
+ placement="right"
+ no-caret
:title="__('Thread options')"
:aria-label="__('Thread options')"
toggle-class="btn-icon"
class="gl-pt-0! gl-px-2 gl-h-full gl-ml-2"
- >
- <template #button-content>
- <gl-icon name="ellipsis_v" class="mr-0" />
- </template>
- <gl-dropdown-item
- data-testid="toggle-all-discussions-btn"
- @click="handleExpandDiscussions"
- >
- {{ toggleThreadsLabel }}
- </gl-dropdown-item>
- </gl-dropdown>
+ :items="threadOptions"
+ />
</template>
<template v-else>
{{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }}
<gl-button-group class="gl-ml-3">
<gl-button
- v-gl-tooltip:discussionCounter.hover.bottom
+ v-gl-tooltip:discussionCounter.hover.top
:title="__('Go to previous unresolved thread')"
:aria-label="__('Go to previous unresolved thread')"
class="discussion-previous-btn gl-rounded-base! gl-px-2!"
@@ -123,7 +130,7 @@ export default {
@click="jumpPrevious"
/>
<gl-button
- v-gl-tooltip:discussionCounter.hover.bottom
+ v-gl-tooltip:discussionCounter.hover.top
:title="__('Go to next unresolved thread')"
:aria-label="__('Go to next unresolved thread')"
class="discussion-next-btn gl-rounded-base! gl-px-2!"
@@ -134,32 +141,19 @@ export default {
category="tertiary"
@click="jumpNext"
/>
- <gl-dropdown
- v-gl-tooltip:discussionCounter.hover.bottom
+ <gl-disclosure-dropdown
+ v-gl-tooltip:discussionCounter.hover.top
+ icon="ellipsis_v"
size="small"
category="tertiary"
- right
+ placement="right"
+ no-caret
:title="__('Thread options')"
:aria-label="__('Thread options')"
toggle-class="btn-icon"
class="gl-pt-0! gl-px-2"
- >
- <template #button-content>
- <gl-icon name="ellipsis_v" class="mr-0" />
- </template>
- <gl-dropdown-item
- data-testid="toggle-all-discussions-btn"
- @click="handleExpandDiscussions"
- >
- {{ toggleThreadsLabel }}
- </gl-dropdown-item>
- <gl-dropdown-item
- v-if="resolveAllDiscussionsIssuePath && !allResolved"
- :href="resolveAllDiscussionsIssuePath"
- >
- {{ __('Resolve all with new issue') }}
- </gl-dropdown-item>
- </gl-dropdown>
+ :items="threadOptions"
+ />
</gl-button-group>
</template>
</div>
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
index 9fb027fb955..080787884c8 100644
--- a/app/assets/javascripts/notes/components/discussion_notes.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -169,7 +169,6 @@ export default {
v-if="hasReplies"
:collapsed="!isExpanded"
:replies="replies"
- :class="{ 'discussion-toggle-replies': discussion.diff_discussion }"
@toggle="toggleDiscussion({ discussionId: discussion.id })"
/>
<template v-if="isExpanded">
diff --git a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue
index 1dd07fe90d2..571928b972b 100644
--- a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue
@@ -21,7 +21,7 @@ export default {
'li',
{
class:
- 'discussion-collapsible gl-border-solid gl-border-gray-100 gl-border-1 gl-rounded-base clearfix',
+ 'discussion-collapsible gl-border-solid gl-border-gray-100 gl-border-1 gl-rounded-base gl-border-top-0',
},
[h('ul', { class: 'notes' }, children)],
);
diff --git a/app/assets/javascripts/notes/components/mr_discussion_filter.vue b/app/assets/javascripts/notes/components/mr_discussion_filter.vue
index 2338c9eef67..7ca0c4730a9 100644
--- a/app/assets/javascripts/notes/components/mr_discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/mr_discussion_filter.vue
@@ -62,6 +62,12 @@ export default {
this.updateMergeRequestFilters(filters);
this.selectedFilters = filters;
},
+ deselectAll() {
+ this.selectedFilters = [];
+ },
+ selectAll() {
+ this.selectedFilters = MR_FILTER_OPTIONS.map((f) => f.value);
+ },
},
MR_FILTER_OPTIONS,
};
@@ -84,9 +90,14 @@ export default {
<gl-collapsible-listbox
v-model="selectedFilters"
:items="$options.MR_FILTER_OPTIONS"
+ :header-text="__('Filter activity')"
+ :show-select-all-button-label="__('Select all')"
+ :reset-button-label="__('Deselect all')"
multiple
placement="right"
@hidden="applyFilters"
+ @reset="deselectAll"
+ @select-all="selectAll"
>
<template #toggle>
<gl-button class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!">
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 47e0ace1ea7..8d2d8095a44 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -1,7 +1,6 @@
<script>
import {
GlTooltipDirective,
- GlIcon,
GlButton,
GlDisclosureDropdown,
GlDisclosureDropdownItem,
@@ -30,15 +29,14 @@ export default {
},
name: 'NoteActions',
components: {
- GlIcon,
- ReplyButton,
- TimelineEventButton,
+ AbuseCategorySelector,
+ EmojiPicker: () => import('~/emoji/components/picker.vue'),
GlButton,
GlDisclosureDropdown,
GlDisclosureDropdownItem,
+ ReplyButton,
+ TimelineEventButton,
UserAccessRoleBadge,
- EmojiPicker: () => import('~/emoji/components/picker.vue'),
- AbuseCategorySelector,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -318,22 +316,12 @@ export default {
/>
<emoji-picker
v-if="canAwardEmoji"
+ v-gl-tooltip
+ :title="$options.i18n.addReactionLabel"
toggle-class="note-action-button note-emoji-button btn-icon btn-default-tertiary"
data-testid="note-emoji-button"
@click="setAwardEmoji"
- >
- <template #button-content>
- <gl-icon class="award-control-icon-neutral gl-button-icon gl-icon" name="slight-smile" />
- <gl-icon
- class="award-control-icon-positive gl-button-icon gl-icon gl-left-3!"
- name="smiley"
- />
- <gl-icon
- class="award-control-icon-super-positive gl-button-icon gl-icon gl-left-3!"
- name="smile"
- />
- </template>
- </emoji-picker>
+ />
<reply-button
v-if="showReply"
ref="replyButton"
@@ -365,7 +353,8 @@ export default {
<gl-disclosure-dropdown
v-gl-tooltip
:title="$options.i18n.moreActionsLabel"
- :aria-label="$options.i18n.moreActionsLabel"
+ :toggle-text="$options.i18n.moreActionsLabel"
+ text-sr-only
icon="ellipsis_v"
category="tertiary"
placement="right"
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index b4e5129ca0e..1c6be0cfd77 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -174,6 +174,7 @@ export default {
:note-id="note.id"
:line="line"
:note="note"
+ :diff-file="file"
:save-button-title="saveButtonTitle"
:help-page-path="helpPagePath"
:discussion="discussion"
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index fe7967f1ed0..4e816038539 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -5,6 +5,7 @@ import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking';
import eventHub from '../event_hub';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
@@ -192,9 +193,6 @@ export default {
markdownDocsPath() {
return this.getNotesDataByProp('markdownDocsPath');
},
- quickActionsDocsPath() {
- return this.getNotesDataByProp('quickActionsDocsPath');
- },
currentUserId() {
return this.getUserDataByProp('id');
},
@@ -223,6 +221,15 @@ export default {
enableContentEditor() {
return Boolean(this.glFeatures.contentEditorOnIssues);
},
+ codeSuggestionsConfig() {
+ return {
+ canSuggest: this.canSuggest,
+ line: this.line,
+ lines: this.lines,
+ showPopover: this.showSuggestPopover,
+ diffFile: this.diffFile,
+ };
+ },
},
watch: {
noteBody() {
@@ -290,6 +297,11 @@ export default {
const beforeSubmitDiscussionState = this.discussionResolved;
this.isSubmitting = true;
+ trackSavedUsingEditor(
+ this.$refs.markdownEditor.isContentEditorActive,
+ `${this.getNoteableData.noteableType}_note`,
+ );
+
this.$emit(
'handleFormUpdate',
this.updatedNoteBody,
@@ -321,7 +333,15 @@ export default {
(!this.discussionResolved && this.isResolving);
this.isSubmitting = true;
- this.$emit('handleFormUpdateAddToReview', this.updatedNoteBody, shouldResolve);
+ this.$emit(
+ 'handleFormUpdateAddToReview',
+ this.updatedNoteBody,
+ shouldResolve,
+ this.$refs.editNoteForm,
+ () => {
+ this.isSubmitting = false;
+ },
+ );
},
hasEmailParticipants() {
return this.getNoteableData.issue_email_participants?.length;
@@ -351,15 +371,11 @@ export default {
:value="updatedNoteBody"
:render-markdown-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
- :line="line"
- :lines="lines"
- :can-suggest="canSuggest"
+ :code-suggestions-config="codeSuggestionsConfig"
:add-spacing-classes="false"
:help-page-path="helpPagePath"
:note="discussionNote"
:form-field-props="formFieldProps"
- :show-suggest-popover="showSuggestPopover"
- :quick-actions-docs-path="quickActionsDocsPath"
:autosave-key="autosaveKey"
:autocomplete-data-sources="autocompleteDataSources"
:disabled="isSubmitting"
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 499581653ba..a5939e1023c 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -15,7 +15,7 @@ import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secr
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
-import { getErrorMessages } from '../utils';
+import { createNoteErrorMessages } from '../utils';
import DiffDiscussionHeader from './diff_discussion_header.vue';
import DiffWithNote from './diff_with_note.vue';
import DiscussionActions from './discussion_actions.vue';
@@ -96,6 +96,18 @@ export default {
'showJumpToNextDiscussion',
'getUserData',
]),
+ diffFile() {
+ const diffFile = this.discussion.diff_file;
+ if (!diffFile) return null;
+
+ return {
+ ...diffFile,
+ view_path: window.location.href.replace(
+ /\/-\/merge_requests.*/,
+ `/-/blob/${diffFile.content_sha}/${diffFile.new_path}`,
+ ),
+ };
+ },
currentUser() {
return this.getUserData;
},
@@ -270,7 +282,7 @@ export default {
});
},
handleSaveError({ response }) {
- const errorMessage = getErrorMessages(response.data, response.status)[0];
+ const errorMessage = createNoteErrorMessages(response.data, response.status)[0];
createAlert({
message: errorMessage,
@@ -331,7 +343,7 @@ export default {
<li
v-else-if="canShowReplyActions && showReplies"
data-testid="reply-wrapper"
- class="discussion-reply-holder gl-border-t-0! clearfix"
+ class="discussion-reply-holder gl-border-t-0! gl-pb-5! clearfix"
:class="discussionHolderClass"
>
<discussion-actions
@@ -348,6 +360,7 @@ export default {
v-if="isReplying"
ref="noteForm"
:discussion="discussion"
+ :diff-file="diffFile"
:line="diffLine"
:save-button-title="saveButtonTitle"
:autosave-key="autosaveKey"
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index dd135eaee3b..69c41af97ab 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -17,8 +17,7 @@ import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secr
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
-import { renderMarkdown } from '../utils';
-import { UPDATE_COMMENT_FORM } from '../i18n';
+import { renderMarkdown, updateNoteErrorMessage } from '../utils';
import {
getStartLineNumber,
getEndLineNumber,
@@ -114,7 +113,6 @@ export default {
isResolving: false,
commentLineStart: {},
resolveAsThread: true,
- oldContent: this.note.note_html,
};
},
computed: {
@@ -212,7 +210,8 @@ export default {
return fileResolvedFromAvailableSource || null;
},
isMRDiffView() {
- return this.line && !this.isOverviewTab;
+ const isFileComment = this.note.position?.position_type === 'file';
+ return !this.isOverviewTab && (this.line || isFileComment);
},
},
created() {
@@ -295,7 +294,7 @@ export default {
updateSuccess() {
this.isEditing = false;
this.isRequesting = false;
- this.oldContent = this.note.note_html;
+ this.oldContent = null;
renderGFM(this.$refs.noteBody.$el);
this.$emit('updateSuccess');
},
@@ -317,7 +316,9 @@ export default {
noteText,
resolveDiscussion,
position,
+ flashContainer: this.$el,
callback: () => this.updateSuccess(),
+ errorCallback: () => callback(),
});
if (this.isDraft) return;
@@ -343,6 +344,7 @@ export default {
// https://gitlab.com/gitlab-org/gitlab/-/issues/298827
if (!isEmpty(position)) data.note.note.position = JSON.stringify(position);
this.isRequesting = true;
+ this.oldContent = this.note.note_html;
// eslint-disable-next-line vue/no-mutating-props
this.note.note_html = renderMarkdown(noteText);
@@ -369,14 +371,8 @@ export default {
});
},
handleUpdateError(e) {
- const serverErrorMessage = e?.response?.data?.errors;
-
- const alertMessage = serverErrorMessage
- ? sprintf(UPDATE_COMMENT_FORM.error, { reason: serverErrorMessage.toLowerCase() }, false)
- : UPDATE_COMMENT_FORM.defaultError;
-
createAlert({
- message: alertMessage,
+ message: updateNoteErrorMessage(e),
parent: this.$el,
});
},
@@ -442,7 +438,12 @@ export default {
</div>
<div v-if="isMRDiffView" class="timeline-avatar gl-float-left gl-pt-2">
- <gl-avatar-link :href="author.path">
+ <gl-avatar-link
+ :href="author.path"
+ :data-user-id="author.id"
+ :data-username="author.username"
+ class="js-user-link"
+ >
<gl-avatar
:src="author.avatar_url"
:entity-name="author.username"
@@ -455,7 +456,12 @@ export default {
</div>
<div v-else class="timeline-avatar gl-float-left">
- <gl-avatar-link :href="author.path">
+ <gl-avatar-link
+ :href="author.path"
+ :data-user-id="author.id"
+ :data-username="author.username"
+ class="js-user-link"
+ >
<gl-avatar
:src="author.avatar_url"
:entity-name="author.username"
diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
index b0f7a4a4732..a012b4411bc 100644
--- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue
+++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
@@ -39,7 +39,7 @@ export default {
},
liClasses() {
return this.collapsed
- ? 'gl-text-gray-500 gl-rounded-bottom-left-base! gl-rounded-bottom-right-base! replies-widget-collapsed'
+ ? 'gl-text-gray-500 gl-rounded-bottom-left-base! gl-rounded-bottom-right-base!'
: 'gl-border-b';
},
buttonIcon() {
diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
index 55a63212dc5..cb6f72538b9 100644
--- a/app/assets/javascripts/notes/mixins/diff_line_note_form.js
+++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
@@ -7,8 +7,9 @@ import {
} from '~/diffs/constants';
import { createAlert } from '~/alert';
import { clearDraft } from '~/lib/utils/autosave';
-import { s__ } from '~/locale';
+import { sprintf } from '~/locale';
import { formatLineRange } from '~/notes/components/multiline_comment_utils';
+import { SAVING_THE_COMMENT_FAILED, SOMETHING_WENT_WRONG } from '~/diffs/i18n';
export default {
computed: {
@@ -24,7 +25,7 @@ export default {
methods: {
...mapActions('diffs', ['cancelCommentForm', 'toggleFileCommentForm']),
...mapActions('batchComments', ['addDraftToReview', 'saveDraft', 'insertDraftIntoDrafts']),
- addReplyToReview(noteText, isResolving) {
+ addReplyToReview(noteText, isResolving, parentElement, errorCallback) {
const postData = getDraftReplyFormData({
in_reply_to_discussion_id: this.discussion.reply_id,
target_type: this.getNoteableData.targetType,
@@ -39,19 +40,26 @@ export default {
postData.note_project_id = this.discussion.project_id;
}
- this.isReplying = false;
-
this.saveDraft(postData)
.then(() => {
+ this.isReplying = false;
this.handleClearForm(this.discussion.line_code);
})
- .catch(() => {
+ .catch((response) => {
+ const reason = response?.data?.errors;
+ const errorMessage = reason
+ ? sprintf(SAVING_THE_COMMENT_FAILED, { reason })
+ : SOMETHING_WENT_WRONG;
+
createAlert({
- message: s__('MergeRequests|An error occurred while saving the draft comment.'),
+ message: errorMessage,
+ parent: parentElement,
});
+
+ errorCallback();
});
},
- addToReview(note, positionType = null) {
+ addToReview(note, positionType = null, parentElement, errorCallback) {
const lineRange =
(this.line && this.commentLineStart && formatLineRange(this.commentLineStart, this.line)) ||
{};
@@ -88,10 +96,18 @@ export default {
this.toggleFileCommentForm(diffFile.file_path);
}
})
- .catch(() => {
+ .catch((response) => {
+ const reason = response?.data?.errors;
+ const errorMessage = reason
+ ? sprintf(SAVING_THE_COMMENT_FAILED, { reason })
+ : SOMETHING_WENT_WRONG;
+
createAlert({
- message: s__('MergeRequests|An error occurred while saving the draft comment.'),
+ message: errorMessage,
+ parent: parentElement,
});
+
+ errorCallback();
});
},
handleClearForm(lineCode) {
diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js
index c5859a89182..a561d26ad56 100644
--- a/app/assets/javascripts/notes/utils.js
+++ b/app/assets/javascripts/notes/utils.js
@@ -4,7 +4,7 @@ import { sanitize } from '~/lib/dompurify';
import { markdownConfig } from '~/lib/utils/text_utility';
import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status';
import { sprintf } from '~/locale';
-import { COMMENT_FORM } from './i18n';
+import { UPDATE_COMMENT_FORM, COMMENT_FORM } from './i18n';
/**
* Tracks snowplow event when User toggles timeline view
@@ -23,7 +23,7 @@ export const renderMarkdown = (rawMarkdown) => {
return sanitize(marked(rawMarkdown), markdownConfig);
};
-export const getErrorMessages = (data, status) => {
+export const createNoteErrorMessages = (data, status) => {
const errors = data?.errors;
if (errors && status === HTTP_STATUS_UNPROCESSABLE_ENTITY) {
@@ -36,3 +36,13 @@ export const getErrorMessages = (data, status) => {
return [COMMENT_FORM.GENERIC_UNSUBMITTABLE_NETWORK];
};
+
+export const updateNoteErrorMessage = (e) => {
+ const errors = e?.response?.data?.errors;
+
+ if (errors) {
+ return sprintf(UPDATE_COMMENT_FORM.error, { reason: errors.toLowerCase() });
+ }
+
+ return UPDATE_COMMENT_FORM.defaultError;
+};
diff --git a/app/assets/javascripts/notifications/components/notification_email_listbox_input.vue b/app/assets/javascripts/notifications/components/notification_email_listbox_input.vue
index 5d5524deb0d..26b8e06a1a7 100644
--- a/app/assets/javascripts/notifications/components/notification_email_listbox_input.vue
+++ b/app/assets/javascripts/notifications/components/notification_email_listbox_input.vue
@@ -5,7 +5,7 @@ export default {
components: {
ListboxInput,
},
- inject: ['label', 'name', 'emails', 'emptyValueText', 'value', 'disabled'],
+ inject: ['label', 'name', 'emails', 'emptyValueText', 'value', 'disabled', 'placement'],
data() {
return {
selected: this.value,
@@ -41,6 +41,8 @@ export default {
:name="name"
:items="options"
:disabled="disabled"
+ :placement="placement"
+ fluid-width
@select="onSelect"
/>
</template>
diff --git a/app/assets/javascripts/notifications/index.js b/app/assets/javascripts/notifications/index.js
index 1395084f68c..d41b1d95854 100644
--- a/app/assets/javascripts/notifications/index.js
+++ b/app/assets/javascripts/notifications/index.js
@@ -7,10 +7,11 @@ import NotificationEmailListboxInput from './components/notification_email_listb
Vue.use(GlToast);
const initNotificationEmailListboxInputs = () => {
- const els = [...document.querySelectorAll('.js-notification-email-listbox-input')];
+ const CLASS_NAME = 'js-notification-email-listbox-input';
+ const els = [...document.querySelectorAll(`.${CLASS_NAME}`)];
els.forEach((el, index) => {
- const { label, name, emptyValueText, value = '' } = el.dataset;
+ const { label, name, emptyValueText, value = '', placement } = el.dataset;
return new Vue({
el,
@@ -22,9 +23,12 @@ const initNotificationEmailListboxInputs = () => {
emptyValueText,
value,
disabled: parseBoolean(el.dataset.disabled),
+ placement,
},
render(h) {
- return h(NotificationEmailListboxInput);
+ return h(NotificationEmailListboxInput, {
+ class: el.className.replace(CLASS_NAME, '').trim(),
+ });
},
});
});
diff --git a/app/assets/javascripts/observability/client.js b/app/assets/javascripts/observability/client.js
new file mode 100644
index 00000000000..251c165e7dd
--- /dev/null
+++ b/app/assets/javascripts/observability/client.js
@@ -0,0 +1,43 @@
+import axios from '~/lib/utils/axios_utils';
+
+function enableTraces() {
+ // TODO remove mocks https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2271
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve();
+ }, 1000);
+ });
+}
+
+function isTracingEnabled() {
+ // TODO remove mocks https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2271
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ // Currently relying on manual provisioning, hence assuming tracing is enabled
+ resolve(true);
+ }, 1000);
+ });
+}
+
+async function fetchTraces(tracingUrl) {
+ const { data } = await axios.get(tracingUrl, { withCredentials: true });
+ if (!Array.isArray(data.traces)) {
+ throw new Error('traces are missing/invalid in the response.'); // eslint-disable-line @gitlab/require-i18n-strings
+ }
+ return data.traces.map((t) => {
+ // aggregating duration on the client for now, but expecting to be coming from the backend
+ const duration = t.spans.reduce((acc, cur) => acc + cur.duration_nano, 0);
+ return {
+ ...t,
+ duration: duration / 1000,
+ };
+ });
+}
+
+export function buildClient({ provisioningUrl, tracingUrl }) {
+ return {
+ enableTraces: () => enableTraces(provisioningUrl),
+ isTracingEnabled: () => isTracingEnabled(provisioningUrl),
+ fetchTraces: () => fetchTraces(tracingUrl),
+ };
+}
diff --git a/app/assets/javascripts/observability/components/observability_container.vue b/app/assets/javascripts/observability/components/observability_container.vue
new file mode 100644
index 00000000000..4306f531ab5
--- /dev/null
+++ b/app/assets/javascripts/observability/components/observability_container.vue
@@ -0,0 +1,92 @@
+<script>
+import { buildClient } from '../client';
+import { SKELETON_SPINNER_VARIANT } from '../constants';
+import ObservabilitySkeleton from './skeleton/index.vue';
+
+export default {
+ SKELETON_SPINNER_VARIANT,
+ components: {
+ ObservabilitySkeleton,
+ },
+ props: {
+ oauthUrl: {
+ type: String,
+ required: true,
+ },
+ tracingUrl: {
+ type: String,
+ required: true,
+ },
+ provisioningUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ observabilityClient: null,
+ authCompleted: false,
+ };
+ },
+ mounted() {
+ window.addEventListener('message', this.messageHandler);
+
+ // TODO Remove once backend work done - just for testing
+ // https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2270
+ // setTimeout(() => {
+ // this.messageHandler({
+ // data: { type: 'AUTH_COMPLETION', status: 'success' },
+ // origin: new URL(this.oauthUrl).origin,
+ // });
+ // }, 2000);
+ },
+ destroyed() {
+ window.removeEventListener('message', this.messageHandler);
+ },
+ methods: {
+ messageHandler(e) {
+ const isExpectedOrigin = e.origin === new URL(this.oauthUrl).origin;
+ if (!isExpectedOrigin) return;
+
+ const { data } = e;
+
+ if (data.type === 'AUTH_COMPLETION') {
+ if (this.authCompleted) return;
+
+ const { status, message, statusCode } = data;
+ if (status === 'success') {
+ this.observabilityClient = buildClient({
+ provisioningUrl: this.provisioningUrl,
+ tracingUrl: this.tracingUrl,
+ });
+ this.$refs.observabilitySkeleton?.onContentLoaded();
+ } else if (status === 'error') {
+ // eslint-disable-next-line @gitlab/require-i18n-strings,no-console
+ console.error('GOB auth failed with error:', message, statusCode);
+ this.$refs.observabilitySkeleton?.onError();
+ }
+ this.authCompleted = true;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <iframe
+ v-if="!authCompleted"
+ sandbox="allow-same-origin allow-forms allow-scripts"
+ hidden
+ :src="oauthUrl"
+ data-testid="observability-oauth-iframe"
+ ></iframe>
+
+ <observability-skeleton
+ ref="observabilitySkeleton"
+ :variant="$options.SKELETON_SPINNER_VARIANT"
+ >
+ <slot v-if="observabilityClient" :observability-client="observabilityClient"></slot>
+ </observability-skeleton>
+ </div>
+</template>
diff --git a/app/assets/javascripts/observability/components/skeleton/index.vue b/app/assets/javascripts/observability/components/skeleton/index.vue
index d91f2874943..4df0f86be1f 100644
--- a/app/assets/javascripts/observability/components/skeleton/index.vue
+++ b/app/assets/javascripts/observability/components/skeleton/index.vue
@@ -1,5 +1,5 @@
<script>
-import { GlSkeletonLoader, GlAlert } from '@gitlab/ui';
+import { GlSkeletonLoader, GlAlert, GlLoadingIcon } from '@gitlab/ui';
import {
SKELETON_VARIANTS_BY_ROUTE,
@@ -9,6 +9,7 @@ import {
TIMEOUT_ERROR_LABEL,
TIMEOUT_ERROR_MESSAGE,
SKELETON_VARIANT_EMBED,
+ SKELETON_SPINNER_VARIANT,
} from '../../constants';
import DashboardsSkeleton from './dashboards.vue';
import ExploreSkeleton from './explore.vue';
@@ -23,6 +24,7 @@ export default {
ManageSkeleton,
EmbedSkeleton,
GlAlert,
+ GlLoadingIcon,
},
SKELETON_VARIANTS_BY_ROUTE,
SKELETON_STATE,
@@ -46,6 +48,23 @@ export default {
errorTimeout: null,
};
},
+ computed: {
+ skeletonVisible() {
+ return this.state === SKELETON_STATE.VISIBLE;
+ },
+ skeletonHidden() {
+ return this.state === SKELETON_STATE.HIDDEN;
+ },
+ errorVisible() {
+ return this.state === SKELETON_STATE.ERROR;
+ },
+ spinnerVariant() {
+ return this.variant === SKELETON_SPINNER_VARIANT;
+ },
+ embedVariant() {
+ return this.variant === SKELETON_VARIANT_EMBED;
+ },
+ },
mounted() {
this.setLoadingTimeout();
this.setErrorTimeout();
@@ -61,6 +80,12 @@ export default {
this.hideSkeleton();
},
+ onError() {
+ clearTimeout(this.errorTimeout);
+ clearTimeout(this.loadingTimeout);
+
+ this.showError();
+ },
setLoadingTimeout() {
this.loadingTimeout = setTimeout(() => {
/**
@@ -92,8 +117,7 @@ export default {
showError() {
this.state = SKELETON_STATE.ERROR;
},
-
- isSkeletonShown(route) {
+ isVariantByRoute(route) {
return this.variant === SKELETON_VARIANTS_BY_ROUTE[route];
},
},
@@ -102,11 +126,12 @@ export default {
<template>
<div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch">
<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)" />
- <embed-skeleton v-else-if="variant === $options.SKELETON_VARIANT_EMBED" />
+ <div v-if="skeletonVisible" class="gl-px-5 gl-my-5">
+ <dashboards-skeleton v-if="isVariantByRoute($options.OBSERVABILITY_ROUTES.DASHBOARDS)" />
+ <explore-skeleton v-else-if="isVariantByRoute($options.OBSERVABILITY_ROUTES.EXPLORE)" />
+ <manage-skeleton v-else-if="isVariantByRoute($options.OBSERVABILITY_ROUTES.MANAGE)" />
+ <embed-skeleton v-else-if="embedVariant" />
+ <gl-loading-icon v-else-if="spinnerVariant" size="lg" />
<gl-skeleton-loader v-else>
<rect y="2" width="10" height="8" />
@@ -115,10 +140,19 @@ export default {
<rect y="15" width="400" height="30" />
</gl-skeleton-loader>
</div>
+
+ <!-- The double condition is only here temporarily for back-compatibility reasons. Will be removed in next iteration https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2275 -->
+ <div
+ v-else-if="spinnerVariant && skeletonHidden"
+ data-testid="content-wrapper"
+ class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch"
+ >
+ <slot></slot>
+ </div>
</transition>
<gl-alert
- v-if="state === $options.SKELETON_STATE.ERROR"
+ v-if="errorVisible"
:title="$options.i18n.TIMEOUT_ERROR_LABEL"
variant="danger"
:dismissible="false"
@@ -127,10 +161,11 @@ export default {
{{ $options.i18n.TIMEOUT_ERROR_MESSAGE }}
</gl-alert>
- <transition>
+ <!-- This is only kept temporarily for back-compatibility reasons. Will be removed in next iteration https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2275 -->
+ <transition v-if="!spinnerVariant">
<div
- v-show="state === $options.SKELETON_STATE.HIDDEN"
- data-testid="observability-wrapper"
+ v-show="skeletonHidden"
+ data-testid="content-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 6b97c51e997..b0a0941779d 100644
--- a/app/assets/javascripts/observability/constants.js
+++ b/app/assets/javascripts/observability/constants.js
@@ -18,6 +18,7 @@ export const SKELETON_VARIANTS_BY_ROUTE = Object.freeze({
});
export const SKELETON_VARIANT_EMBED = 'embed';
+export const SKELETON_SPINNER_VARIANT = 'spinner';
export const SKELETON_STATE = Object.freeze({
ERROR: 'error',
diff --git a/app/assets/javascripts/observability/mock_traces.json b/app/assets/javascripts/observability/mock_traces.json
new file mode 100644
index 00000000000..6f83f718d96
--- /dev/null
+++ b/app/assets/javascripts/observability/mock_traces.json
@@ -0,0 +1,2807 @@
+{
+ "project_id": "1",
+ "message": "",
+ "traces": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677538Z",
+ "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677538Z",
+ "span_id": "E2CB9B54BB6FCAC1",
+ "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 147000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677561Z",
+ "span_id": "4B29015A902EF378",
+ "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677538Z",
+ "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677538Z",
+ "span_id": "E2CB9B54BB6FCAC1",
+ "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 147000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677561Z",
+ "span_id": "4B29015A902EF378",
+ "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.67758Z",
+ "trace_id": "36f65703-d085-0674-a589-b35db23f77d5",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.67758Z",
+ "span_id": "F0788D69026E13A1",
+ "trace_id": "36f65703-d085-0674-a589-b35db23f77d5",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677581Z",
+ "span_id": "14987F8F6FDD27AE",
+ "trace_id": "36f65703-d085-0674-a589-b35db23f77d5",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.67758Z",
+ "trace_id": "36f65703-d085-0674-a589-b35db23f77d5",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.67758Z",
+ "span_id": "F0788D69026E13A1",
+ "trace_id": "36f65703-d085-0674-a589-b35db23f77d5",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677581Z",
+ "span_id": "14987F8F6FDD27AE",
+ "trace_id": "36f65703-d085-0674-a589-b35db23f77d5",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677583Z",
+ "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677583Z",
+ "span_id": "F5AB66F29F53ECAF",
+ "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677584Z",
+ "span_id": "17D79F52E57E9C6A",
+ "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677583Z",
+ "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677583Z",
+ "span_id": "F5AB66F29F53ECAF",
+ "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677584Z",
+ "span_id": "17D79F52E57E9C6A",
+ "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677585Z",
+ "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677585Z",
+ "span_id": "468B2959252EDA28",
+ "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677586Z",
+ "span_id": "7AC8860F5CB85E0A",
+ "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677585Z",
+ "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677585Z",
+ "span_id": "468B2959252EDA28",
+ "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677586Z",
+ "span_id": "7AC8860F5CB85E0A",
+ "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677588Z",
+ "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677588Z",
+ "span_id": "3411BDD296DB9370",
+ "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677589Z",
+ "span_id": "1774F16A178B8FCB",
+ "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677588Z",
+ "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677588Z",
+ "span_id": "3411BDD296DB9370",
+ "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677589Z",
+ "span_id": "1774F16A178B8FCB",
+ "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677613Z",
+ "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677613Z",
+ "span_id": "CDACF24BB78C3534",
+ "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677614Z",
+ "span_id": "7B69777569B9EA84",
+ "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677613Z",
+ "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677613Z",
+ "span_id": "CDACF24BB78C3534",
+ "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677614Z",
+ "span_id": "7B69777569B9EA84",
+ "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677616Z",
+ "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677616Z",
+ "span_id": "1265DF31CD5EC4E2",
+ "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677617Z",
+ "span_id": "3E0260222F729537",
+ "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677616Z",
+ "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677616Z",
+ "span_id": "1265DF31CD5EC4E2",
+ "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677617Z",
+ "span_id": "3E0260222F729537",
+ "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677619Z",
+ "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677619Z",
+ "span_id": "3A06AC7ABCA9D043",
+ "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677619Z",
+ "span_id": "9C99F917736586E1",
+ "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677619Z",
+ "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677619Z",
+ "span_id": "3A06AC7ABCA9D043",
+ "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677619Z",
+ "span_id": "9C99F917736586E1",
+ "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677621Z",
+ "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677621Z",
+ "span_id": "B2417463C771A704",
+ "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677622Z",
+ "span_id": "897DD866880697F0",
+ "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677621Z",
+ "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677621Z",
+ "span_id": "B2417463C771A704",
+ "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677622Z",
+ "span_id": "897DD866880697F0",
+ "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677637Z",
+ "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677637Z",
+ "span_id": "AB982E41826E4CB4",
+ "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677638Z",
+ "span_id": "8577639018E3ACE2",
+ "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677637Z",
+ "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677637Z",
+ "span_id": "AB982E41826E4CB4",
+ "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677638Z",
+ "span_id": "8577639018E3ACE2",
+ "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677651Z",
+ "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677651Z",
+ "span_id": "E4D0C62B763FC25C",
+ "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677653Z",
+ "span_id": "C059EDEE59610CB1",
+ "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677651Z",
+ "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677651Z",
+ "span_id": "E4D0C62B763FC25C",
+ "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677653Z",
+ "span_id": "C059EDEE59610CB1",
+ "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677654Z",
+ "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677654Z",
+ "span_id": "EF63FD474F6898CE",
+ "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677655Z",
+ "span_id": "694494E5AA2A2763",
+ "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677654Z",
+ "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677654Z",
+ "span_id": "EF63FD474F6898CE",
+ "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677655Z",
+ "span_id": "694494E5AA2A2763",
+ "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677657Z",
+ "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677657Z",
+ "span_id": "A7C41B19AC60C808",
+ "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677657Z",
+ "span_id": "57A5EDD6AF5CEB5B",
+ "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677657Z",
+ "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677657Z",
+ "span_id": "A7C41B19AC60C808",
+ "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677657Z",
+ "span_id": "57A5EDD6AF5CEB5B",
+ "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677659Z",
+ "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677659Z",
+ "span_id": "EA174F950C4D04D8",
+ "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.67766Z",
+ "span_id": "BA21BB5236E2EF8E",
+ "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677659Z",
+ "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677659Z",
+ "span_id": "EA174F950C4D04D8",
+ "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.67766Z",
+ "span_id": "BA21BB5236E2EF8E",
+ "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677661Z",
+ "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677661Z",
+ "span_id": "BF7E718C91691CBE",
+ "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 129000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677666Z",
+ "span_id": "50F782517AF36EA8",
+ "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677661Z",
+ "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677661Z",
+ "span_id": "BF7E718C91691CBE",
+ "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 129000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677666Z",
+ "span_id": "50F782517AF36EA8",
+ "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677668Z",
+ "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677668Z",
+ "span_id": "3B5B0841228A565D",
+ "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 131000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677669Z",
+ "span_id": "62ADBDBA30734B48",
+ "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 130000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677668Z",
+ "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677668Z",
+ "span_id": "3B5B0841228A565D",
+ "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 131000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677669Z",
+ "span_id": "62ADBDBA30734B48",
+ "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 130000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677685Z",
+ "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677685Z",
+ "span_id": "8A9D888CF37A3F57",
+ "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677686Z",
+ "span_id": "388636C7D201F9FB",
+ "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677685Z",
+ "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677685Z",
+ "span_id": "8A9D888CF37A3F57",
+ "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677686Z",
+ "span_id": "388636C7D201F9FB",
+ "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677688Z",
+ "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677688Z",
+ "span_id": "9E84A870338BBACA",
+ "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 128000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677693Z",
+ "span_id": "FC1665CC8A7536B6",
+ "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677688Z",
+ "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677688Z",
+ "span_id": "9E84A870338BBACA",
+ "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 128000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677693Z",
+ "span_id": "FC1665CC8A7536B6",
+ "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677695Z",
+ "trace_id": "5154401c-5126-856b-9474-29efb69b8588",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677695Z",
+ "span_id": "1B5140A527AC99F8",
+ "trace_id": "5154401c-5126-856b-9474-29efb69b8588",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677695Z",
+ "span_id": "6C328C8E60FBB3A8",
+ "trace_id": "5154401c-5126-856b-9474-29efb69b8588",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677695Z",
+ "trace_id": "5154401c-5126-856b-9474-29efb69b8588",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677695Z",
+ "span_id": "1B5140A527AC99F8",
+ "trace_id": "5154401c-5126-856b-9474-29efb69b8588",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677695Z",
+ "span_id": "6C328C8E60FBB3A8",
+ "trace_id": "5154401c-5126-856b-9474-29efb69b8588",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677697Z",
+ "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677697Z",
+ "span_id": "BC05CC86EF04A641",
+ "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 129000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677703Z",
+ "span_id": "3DAF5282D4311F57",
+ "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677697Z",
+ "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677697Z",
+ "span_id": "BC05CC86EF04A641",
+ "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 129000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677703Z",
+ "span_id": "3DAF5282D4311F57",
+ "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677707Z",
+ "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677707Z",
+ "span_id": "AEAF8AC47E800113",
+ "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677708Z",
+ "span_id": "D6314BCF73DC741F",
+ "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677707Z",
+ "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677707Z",
+ "span_id": "AEAF8AC47E800113",
+ "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677708Z",
+ "span_id": "D6314BCF73DC741F",
+ "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677718Z",
+ "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677718Z",
+ "span_id": "D0BEDA55261815BE",
+ "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677719Z",
+ "span_id": "E1FA20547B7056ED",
+ "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677718Z",
+ "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677718Z",
+ "span_id": "D0BEDA55261815BE",
+ "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677719Z",
+ "span_id": "E1FA20547B7056ED",
+ "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677721Z",
+ "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677721Z",
+ "span_id": "B171E7315A8B7FD1",
+ "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677721Z",
+ "span_id": "CD8D690AC2924C06",
+ "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677721Z",
+ "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677721Z",
+ "span_id": "B171E7315A8B7FD1",
+ "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677721Z",
+ "span_id": "CD8D690AC2924C06",
+ "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677723Z",
+ "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677723Z",
+ "span_id": "224B0CB973E7D237",
+ "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677724Z",
+ "span_id": "452D473BC85DDBA5",
+ "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677723Z",
+ "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677723Z",
+ "span_id": "224B0CB973E7D237",
+ "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677724Z",
+ "span_id": "452D473BC85DDBA5",
+ "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677726Z",
+ "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677726Z",
+ "span_id": "6491B39AA9F2CB97",
+ "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677727Z",
+ "span_id": "96C2E96EA8D3AF1D",
+ "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677726Z",
+ "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677726Z",
+ "span_id": "6491B39AA9F2CB97",
+ "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677727Z",
+ "span_id": "96C2E96EA8D3AF1D",
+ "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677728Z",
+ "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677728Z",
+ "span_id": "3A7F5394923A5AF0",
+ "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677729Z",
+ "span_id": "24CC4AB1032650CF",
+ "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677728Z",
+ "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677728Z",
+ "span_id": "3A7F5394923A5AF0",
+ "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677729Z",
+ "span_id": "24CC4AB1032650CF",
+ "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677743Z",
+ "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677743Z",
+ "span_id": "F783055E10DA9E2D",
+ "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677744Z",
+ "span_id": "E581CC7A539137BF",
+ "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677743Z",
+ "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677743Z",
+ "span_id": "F783055E10DA9E2D",
+ "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677744Z",
+ "span_id": "E581CC7A539137BF",
+ "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677746Z",
+ "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677746Z",
+ "span_id": "89BD651A0E16281F",
+ "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677747Z",
+ "span_id": "1FADCC9FB8DDE886",
+ "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677746Z",
+ "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677746Z",
+ "span_id": "89BD651A0E16281F",
+ "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677747Z",
+ "span_id": "1FADCC9FB8DDE886",
+ "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677749Z",
+ "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677749Z",
+ "span_id": "09E59AECCEDE7725",
+ "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.67775Z",
+ "span_id": "9885AAE43420A45B",
+ "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677749Z",
+ "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677749Z",
+ "span_id": "09E59AECCEDE7725",
+ "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.67775Z",
+ "span_id": "9885AAE43420A45B",
+ "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677751Z",
+ "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677751Z",
+ "span_id": "CC204570C29BDBF4",
+ "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677752Z",
+ "span_id": "D17C651E1245C0F9",
+ "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677751Z",
+ "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677751Z",
+ "span_id": "CC204570C29BDBF4",
+ "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677752Z",
+ "span_id": "D17C651E1245C0F9",
+ "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677755Z",
+ "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677755Z",
+ "span_id": "B9C3F1DAF9940B7F",
+ "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677755Z",
+ "span_id": "06A614DD43EC1E9A",
+ "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677755Z",
+ "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677755Z",
+ "span_id": "B9C3F1DAF9940B7F",
+ "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677755Z",
+ "span_id": "06A614DD43EC1E9A",
+ "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677757Z",
+ "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677757Z",
+ "span_id": "46A99707D5225859",
+ "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 131000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677763Z",
+ "span_id": "F489DDD88539BDA4",
+ "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677757Z",
+ "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677757Z",
+ "span_id": "46A99707D5225859",
+ "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 131000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677763Z",
+ "span_id": "F489DDD88539BDA4",
+ "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677773Z",
+ "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677773Z",
+ "span_id": "3223B8A1131D2A70",
+ "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677774Z",
+ "span_id": "82904DC8C7ED5487",
+ "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677773Z",
+ "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677773Z",
+ "span_id": "3223B8A1131D2A70",
+ "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677774Z",
+ "span_id": "82904DC8C7ED5487",
+ "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677776Z",
+ "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677776Z",
+ "span_id": "D9EC63C08230FB02",
+ "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677777Z",
+ "span_id": "F504530C5C200E2E",
+ "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677776Z",
+ "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677776Z",
+ "span_id": "D9EC63C08230FB02",
+ "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677777Z",
+ "span_id": "F504530C5C200E2E",
+ "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677778Z",
+ "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677778Z",
+ "span_id": "6F0F8D30DF04BA3E",
+ "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677779Z",
+ "span_id": "2FF73BB3675EBE65",
+ "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677778Z",
+ "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677778Z",
+ "span_id": "6F0F8D30DF04BA3E",
+ "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677779Z",
+ "span_id": "2FF73BB3675EBE65",
+ "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.67778Z",
+ "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.67778Z",
+ "span_id": "3D79FEC1831E8F1A",
+ "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677781Z",
+ "span_id": "35D99AA84BCD627A",
+ "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.67778Z",
+ "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.67778Z",
+ "span_id": "3D79FEC1831E8F1A",
+ "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677781Z",
+ "span_id": "35D99AA84BCD627A",
+ "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677783Z",
+ "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677783Z",
+ "span_id": "0077A5FDB210BAB9",
+ "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 130000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677783Z",
+ "span_id": "BD4D0517234DC84A",
+ "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 130000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677783Z",
+ "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677783Z",
+ "span_id": "0077A5FDB210BAB9",
+ "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 130000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677783Z",
+ "span_id": "BD4D0517234DC84A",
+ "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 130000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677793Z",
+ "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677793Z",
+ "span_id": "C148B50CA183BF05",
+ "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677794Z",
+ "span_id": "91C5D79D971A4A6E",
+ "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677793Z",
+ "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677793Z",
+ "span_id": "C148B50CA183BF05",
+ "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677794Z",
+ "span_id": "91C5D79D971A4A6E",
+ "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677804Z",
+ "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677804Z",
+ "span_id": "948B20672FD5954F",
+ "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677805Z",
+ "span_id": "529CA18AF8EF5017",
+ "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677804Z",
+ "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677804Z",
+ "span_id": "948B20672FD5954F",
+ "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677805Z",
+ "span_id": "529CA18AF8EF5017",
+ "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677806Z",
+ "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677806Z",
+ "span_id": "ADF60B9EFA97AD89",
+ "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677807Z",
+ "span_id": "03DE70E55CBF857C",
+ "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677806Z",
+ "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677806Z",
+ "span_id": "ADF60B9EFA97AD89",
+ "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677807Z",
+ "span_id": "03DE70E55CBF857C",
+ "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677809Z",
+ "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677809Z",
+ "span_id": "4350413FDF6C0DCD",
+ "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 128000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677814Z",
+ "span_id": "BDC8BD58C638FC78",
+ "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677809Z",
+ "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677809Z",
+ "span_id": "4350413FDF6C0DCD",
+ "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 128000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677814Z",
+ "span_id": "BDC8BD58C638FC78",
+ "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677815Z",
+ "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677815Z",
+ "span_id": "1A9A97C656E06304",
+ "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677816Z",
+ "span_id": "5C6D85FF7954A628",
+ "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677815Z",
+ "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677815Z",
+ "span_id": "1A9A97C656E06304",
+ "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677816Z",
+ "span_id": "5C6D85FF7954A628",
+ "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677819Z",
+ "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677819Z",
+ "span_id": "6ED8D4E93C42E03E",
+ "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.67782Z",
+ "span_id": "E3A02E872E9E95EA",
+ "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677819Z",
+ "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677819Z",
+ "span_id": "6ED8D4E93C42E03E",
+ "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.67782Z",
+ "span_id": "E3A02E872E9E95EA",
+ "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677824Z",
+ "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677824Z",
+ "span_id": "2AF1B764C7572560",
+ "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677825Z",
+ "span_id": "321BA81B83ABAB3C",
+ "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677824Z",
+ "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677824Z",
+ "span_id": "2AF1B764C7572560",
+ "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677825Z",
+ "span_id": "321BA81B83ABAB3C",
+ "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677835Z",
+ "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677835Z",
+ "span_id": "3A2C3D8803D79AC4",
+ "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677836Z",
+ "span_id": "B44FBDD3FD165A7D",
+ "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677835Z",
+ "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677835Z",
+ "span_id": "3A2C3D8803D79AC4",
+ "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677836Z",
+ "span_id": "B44FBDD3FD165A7D",
+ "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677838Z",
+ "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677838Z",
+ "span_id": "B3541547AC06BBBE",
+ "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677838Z",
+ "span_id": "D9A66F89D75DF7A9",
+ "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677838Z",
+ "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677838Z",
+ "span_id": "B3541547AC06BBBE",
+ "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677838Z",
+ "span_id": "D9A66F89D75DF7A9",
+ "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.67784Z",
+ "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.67784Z",
+ "span_id": "6319FEA0EAA4E4C2",
+ "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677841Z",
+ "span_id": "ACF1D215C677342D",
+ "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.67784Z",
+ "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.67784Z",
+ "span_id": "6319FEA0EAA4E4C2",
+ "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677841Z",
+ "span_id": "ACF1D215C677342D",
+ "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 123000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677847Z",
+ "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677847Z",
+ "span_id": "BFC1E9D5F3C3DC01",
+ "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 126000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677848Z",
+ "span_id": "021083418A0CC7D6",
+ "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677847Z",
+ "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677847Z",
+ "span_id": "BFC1E9D5F3C3DC01",
+ "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 126000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677848Z",
+ "span_id": "021083418A0CC7D6",
+ "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 125000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677877Z",
+ "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677877Z",
+ "span_id": "D732E63B1D99C410",
+ "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677877Z",
+ "span_id": "2115BD5B480ED78A",
+ "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677877Z",
+ "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.677877Z",
+ "span_id": "D732E63B1D99C410",
+ "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677877Z",
+ "span_id": "2115BD5B480ED78A",
+ "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.67788Z",
+ "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.67788Z",
+ "span_id": "479F386B26842545",
+ "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 129000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677885Z",
+ "span_id": "AC44E5C2E91E801A",
+ "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.67788Z",
+ "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "statusCode": "STATUS_CODE_UNSET",
+ "spans": [
+ {
+ "timestamp": "2023-07-10T15:02:30.67788Z",
+ "span_id": "479F386B26842545",
+ "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11",
+ "service_name": "tracegen",
+ "operation": "lets-go",
+ "duration_nano": 129000,
+ "statusCode": "STATUS_CODE_UNSET"
+ },
+ {
+ "timestamp": "2023-07-10T15:02:30.677885Z",
+ "span_id": "AC44E5C2E91E801A",
+ "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11",
+ "service_name": "tracegen",
+ "operation": "okey-dokey",
+ "duration_nano": 124000,
+ "statusCode": "STATUS_CODE_UNSET"
+ }
+ ],
+ "totalSpans": 2
+ }
+ ],
+ "totalTraces": 200
+}
diff --git a/app/assets/javascripts/organizations/groups_and_projects/components/app.vue b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue
new file mode 100644
index 00000000000..2b42c821cd5
--- /dev/null
+++ b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue
@@ -0,0 +1,62 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { createAlert } from '~/alert';
+import projectsQuery from '../graphql/queries/projects.query.graphql';
+
+export default {
+ i18n: {
+ pageTitle: __('Groups and projects'),
+ errorMessage: s__(
+ 'Organization|An error occurred loading the projects. Please refresh the page to try again.',
+ ),
+ },
+ components: {
+ ProjectsList,
+ GlLoadingIcon,
+ },
+ data() {
+ return {
+ projects: [],
+ };
+ },
+ apollo: {
+ projects: {
+ query: projectsQuery,
+ update(data) {
+ return data.organization.projects.nodes;
+ },
+ error(error) {
+ createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true });
+ },
+ },
+ },
+ computed: {
+ formattedProjects() {
+ return this.projects.map(({ id, nameWithNamespace, accessLevel, ...project }) => ({
+ ...project,
+ id: getIdFromGraphQLId(id),
+ name: nameWithNamespace,
+ permissions: {
+ projectAccess: {
+ accessLevel: accessLevel.integerValue,
+ },
+ },
+ }));
+ },
+ isLoading() {
+ return this.$apollo.queries.projects?.loading;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h1 class="gl-font-size-h-display">{{ $options.i18n.pageTitle }}</h1>
+ <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" />
+ <projects-list v-else :projects="formattedProjects" show-project-icon />
+ </div>
+</template>
diff --git a/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql b/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql
new file mode 100644
index 00000000000..b4cb8c607d4
--- /dev/null
+++ b/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql
@@ -0,0 +1,24 @@
+query getOrganizationProjects {
+ organization @client {
+ id
+ projects {
+ nodes {
+ id
+ nameWithNamespace
+ webUrl
+ topics
+ forksCount
+ avatarUrl
+ starCount
+ visibility
+ openIssuesCount
+ descriptionHtml
+ issuesAccessLevel
+ forkingAccessLevel
+ accessLevel {
+ integerValue
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js b/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js
new file mode 100644
index 00000000000..794410c2a78
--- /dev/null
+++ b/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js
@@ -0,0 +1,14 @@
+import { organizationProjects } from 'jest/organizations/groups_and_projects/components/mock_data';
+
+export default {
+ Query: {
+ organization: async () => {
+ // Simulate API loading
+ await new Promise((resolve) => {
+ setTimeout(resolve, 1000);
+ });
+
+ return organizationProjects;
+ },
+ },
+};
diff --git a/app/assets/javascripts/organizations/groups_and_projects/index.js b/app/assets/javascripts/organizations/groups_and_projects/index.js
new file mode 100644
index 00000000000..d0790bcc040
--- /dev/null
+++ b/app/assets/javascripts/organizations/groups_and_projects/index.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import resolvers from './graphql/resolvers';
+import App from './components/app.vue';
+
+export const initOrganizationsGroupsAndProjects = () => {
+ const el = document.getElementById('js-organizations-groups-and-projects');
+
+ if (!el) return false;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(resolvers),
+ });
+
+ return new Vue({
+ el,
+ name: 'OrganizationsGroupsAndProjects',
+ apolloProvider,
+ render(createElement) {
+ return createElement(App);
+ },
+ });
+};
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 8e89128a382..a3f58cc3323 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
@@ -4,8 +4,7 @@ import {
GlTooltipDirective,
GlSprintf,
GlIcon,
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
} from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility';
import { numberToHumanSize } from '~/lib/utils/number_utils';
@@ -33,8 +32,7 @@ export default {
GlSprintf,
GlFormCheckbox,
GlIcon,
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
ListItem,
ClipboardButton,
TimeAgoTooltip,
@@ -76,6 +74,22 @@ export default {
COPY_IMAGE_PATH_TITLE,
},
computed: {
+ items() {
+ return [
+ {
+ text: this.$options.i18n.REMOVE_TAG_BUTTON_TITLE,
+ extraAttrs: {
+ class: 'gl-text-red-500!',
+ 'data-testid': 'single-delete-button',
+ 'data-qa-selector': 'tag_delete_button',
+ },
+ action: () => {
+ this.$emit('delete');
+ },
+ },
+ ];
+ },
+
formattedSize() {
return this.tag.totalSize
? numberToHumanSize(Number(this.tag.totalSize))
@@ -177,31 +191,23 @@ export default {
</span>
</template>
<template v-if="tag.canDelete" #right-action>
- <gl-dropdown
+ <gl-disclosure-dropdown
:disabled="disabled"
icon="ellipsis_v"
- :text="$options.i18n.MORE_ACTIONS_TEXT"
+ :toggle-text="$options.i18n.MORE_ACTIONS_TEXT"
:text-sr-only="true"
category="tertiary"
no-caret
- right
+ placement="right"
:class="{ 'gl-opacity-0 gl-pointer-events-none': disabled }"
data-testid="additional-actions"
data-qa-selector="more_actions_menu"
- >
- <gl-dropdown-item
- variant="danger"
- data-testid="single-delete-button"
- data-qa-selector="tag_delete_button"
- @click="$emit('delete')"
- >
- {{ $options.i18n.REMOVE_TAG_BUTTON_TITLE }}
- </gl-dropdown-item>
- </gl-dropdown>
+ :items="items"
+ />
</template>
<template v-if="!isInvalidTag" #details-published>
- <details-row icon="clock" data-testid="published-date-detail">
+ <details-row icon="clock" padding="gl-py-3" data-testid="published-date-detail">
<gl-sprintf :message="$options.i18n.PUBLISHED_DETAILS_ROW_TEXT">
<template #repositoryPath>
<i>{{ tagLocation }}</i>
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
index 732d544816b..87a2eb362d5 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
@@ -4,9 +4,9 @@ import {
GlButton,
GlDropdown,
GlDropdownItem,
- GlEmptyState,
GlFormGroup,
GlFormInputGroup,
+ GlSkeletonLoader,
GlModal,
GlModalDirective,
GlSprintf,
@@ -17,7 +17,6 @@ import Api from '~/api';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import ManifestsList from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue';
-import { DEPENDENCY_PROXY_DOCS_PATH } from '~/packages_and_registries/settings/group/constants';
import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/dependency_proxy/constants';
import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql';
@@ -28,7 +27,7 @@ export default {
GlButton,
GlDropdown,
GlDropdownItem,
- GlEmptyState,
+ GlSkeletonLoader,
GlFormGroup,
GlFormInputGroup,
GlModal,
@@ -41,13 +40,12 @@ export default {
GlModalDirective,
GlTooltip: GlTooltipDirective,
},
- inject: ['groupPath', 'groupId', 'noManifestsIllustration', 'canClearCache', 'settingsPath'],
+ inject: ['groupPath', 'groupId', 'canClearCache', 'settingsPath'],
i18n: {
proxyImagePrefix: s__('DependencyProxy|Dependency Proxy image prefix'),
copyImagePrefixText: s__('DependencyProxy|Copy prefix'),
blobCountAndSize: s__('DependencyProxy|Contains %{count} blobs of images (%{size})'),
pageTitle: s__('DependencyProxy|Dependency Proxy'),
- noManifestTitle: s__('DependencyProxy|There are no images in the cache'),
deleteCacheAlertMessageSuccess: s__(
'DependencyProxy|All items in the cache are scheduled for removal.',
),
@@ -64,9 +62,6 @@ export default {
text: __('Cancel'),
},
},
- links: {
- DEPENDENCY_PROXY_DOCS_PATH,
- },
data() {
return {
group: {},
@@ -90,7 +85,7 @@ export default {
return this.group.dependencyProxyManifests?.pageInfo;
},
manifests() {
- return this.group.dependencyProxyManifests?.nodes;
+ return this.group.dependencyProxyManifests?.nodes ?? [];
},
modalTitleWithCount() {
return sprintf(
@@ -199,10 +194,16 @@ export default {
</template>
</title-area>
- <gl-form-group v-if="showDependencyProxyImagePrefix" :label="$options.i18n.proxyImagePrefix">
+ <gl-form-group
+ v-if="showDependencyProxyImagePrefix"
+ :label="$options.i18n.proxyImagePrefix"
+ label-for="proxy-url"
+ >
<gl-form-input-group
+ id="proxy-url"
readonly
:value="group.dependencyProxyImagePrefix"
+ select-on-click
class="gl-layout-w-limited"
data-testid="proxy-url"
>
@@ -222,9 +223,9 @@ export default {
</span>
</template>
</gl-form-group>
+ <gl-skeleton-loader v-else-if="$apollo.queries.group.loading" />
<manifests-list
- v-if="manifests && manifests.length"
:dependency-proxy-image-prefix="dependencyProxyImagePrefix"
:loading="$apollo.queries.group.loading"
:manifests="manifests"
@@ -233,12 +234,6 @@ export default {
@next-page="fetchNextPage"
/>
- <gl-empty-state
- v-else
- :svg-path="noManifestsIllustration"
- :title="$options.i18n.noManifestTitle"
- />
-
<gl-modal
:modal-id="$options.confirmClearCacheModal"
:title="modalTitleWithCount"
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_empty_state.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_empty_state.vue
new file mode 100644
index 00000000000..b0d03a7cebe
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_empty_state.vue
@@ -0,0 +1,80 @@
+<script>
+import { GlEmptyState, GlFormGroup, GlFormInputGroup, GlLink, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { DEPENDENCY_PROXY_HELP_PAGE_PATH } from '~/packages_and_registries/dependency_proxy/constants';
+
+export default {
+ name: 'ManifestsEmptyState',
+ components: {
+ ClipboardButton,
+ GlEmptyState,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlLink,
+ GlSprintf,
+ },
+ inject: ['noManifestsIllustration'],
+ i18n: {
+ codeExampleLabel: s__('DependencyProxy|Pull image by digest example'),
+ noManifestTitle: s__('DependencyProxy|There are no images in the cache'),
+ emptyText: s__(
+ 'DependencyProxy|To store docker images in Dependency Proxy cache, pull an image by tag in your %{codeStart}.gitlab-ci.yml%{codeEnd} file. In this example, the image is %{codeStart}alpine:latest%{codeEnd}',
+ ),
+ documentationText: s__(
+ 'DependencyProxy|%{docLinkStart}See the documentation%{docLinkEnd} for other ways to store Docker images in Dependency Proxy cache.',
+ ),
+ copyExample: s__('DependencyProxy|Copy example'),
+ },
+ // eslint-disable-next-line no-template-curly-in-string
+ codeExample: 'image: ${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}/alpine:latest',
+ links: {
+ DEPENDENCY_PROXY_HELP_PAGE_PATH,
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state :svg-path="noManifestsIllustration" :title="$options.i18n.noManifestTitle">
+ <template #description>
+ <p class="gl-mb-5">
+ <gl-sprintf :message="$options.i18n.emptyText">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <gl-form-group
+ class="gl-mb-5"
+ :label="$options.i18n.codeExampleLabel"
+ label-for="code-example"
+ label-sr-only
+ >
+ <gl-form-input-group
+ id="code-example"
+ readonly
+ :value="$options.codeExample"
+ class="gl-w-70p gl-mx-auto"
+ select-on-click
+ >
+ <template #append>
+ <clipboard-button
+ :text="$options.codeExample"
+ :title="$options.i18n.copyExample"
+ class="gl-m-0!"
+ />
+ </template>
+ </gl-form-input-group>
+ </gl-form-group>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.documentationText">
+ <template #docLink="{ content }">
+ <gl-link :href="$options.links.DEPENDENCY_PROXY_HELP_PAGE_PATH">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
index 9870841f1ff..94c958308dd 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
@@ -2,11 +2,13 @@
import { GlKeysetPagination, GlSkeletonLoader } from '@gitlab/ui';
import { s__ } from '~/locale';
import ManifestRow from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue';
+import ManifestsEmptyState from '~/packages_and_registries/dependency_proxy/components/manifests_empty_state.vue';
export default {
name: 'ManifestsLists',
components: {
ManifestRow,
+ ManifestsEmptyState,
GlKeysetPagination,
GlSkeletonLoader,
},
@@ -18,7 +20,8 @@ export default {
},
pagination: {
type: Object,
- required: true,
+ required: false,
+ default: () => ({}),
},
loading: {
type: Boolean,
@@ -44,12 +47,18 @@ export default {
<template>
<div class="gl-mt-6">
- <h3 class="gl-font-base">{{ $options.i18n.listTitle }}</h3>
- <gl-skeleton-loader v-if="loading" />
+ <h3 class="gl-font-base gl-pb-3 gl-mb-0 gl-border-b-1 gl-border-gray-100 gl-border-b-solid">
+ {{ $options.i18n.listTitle }}
+ </h3>
+
+ <div v-if="loading" class="gl-py-3">
+ <gl-skeleton-loader />
+ </div>
+
+ <manifests-empty-state v-else-if="manifests.length === 0" />
+
<div v-else data-testid="main-area">
- <div
- class="gl-border-t-1 gl-border-gray-100 gl-border-t-solid gl-display-flex gl-flex-direction-column"
- >
+ <div class="gl-display-flex gl-flex-direction-column">
<manifest-row
v-for="(manifest, index) in manifests"
:key="index"
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js
index fdad69204ba..8e88df92155 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js
@@ -1,2 +1,11 @@
+import { helpPagePath } from '~/helpers/help_page_helper';
+
export const GRAPHQL_PAGE_SIZE = 20;
export const MANIFEST_PENDING_DESTRUCTION_STATUS = 'PENDING_DESTRUCTION';
+
+export const DEPENDENCY_PROXY_HELP_PAGE_PATH = helpPagePath(
+ 'user/packages/dependency_proxy/index',
+ {
+ anchor: 'store-a-docker-image-in-dependency-proxy-cache',
+ },
+);
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
index 3157653648b..96d097eff38 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
@@ -3,16 +3,20 @@ import {
GlAlert,
GlLink,
GlTable,
- GlDropdownItem,
- GlDropdown,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
GlButton,
GlFormCheckbox,
GlLoadingIcon,
GlModal,
GlSprintf,
+ GlKeysetPagination,
} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert';
+import { NEXT, PREV } from '~/vue_shared/components/pagination/constants';
import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { scrollToElement } from '~/lib/utils/common_utils';
import { __, s__ } from '~/locale';
import FileSha from '~/packages_and_registries/package_registry/components/details/file_sha.vue';
import Tracking from '~/tracking';
@@ -47,12 +51,13 @@ export default {
GlAlert,
GlLink,
GlTable,
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
GlFormCheckbox,
GlButton,
GlLoadingIcon,
GlModal,
+ GlKeysetPagination,
GlSprintf,
FileIcon,
TimeAgoTooltip,
@@ -94,10 +99,17 @@ export default {
return this.queryVariables;
},
update(data) {
- return data.package?.packageFiles ?? {};
+ return data.package?.packageFiles?.nodes ?? [];
},
- error() {
+ result({ data }) {
+ const { packageFiles } = data?.package ?? {};
+ if (packageFiles?.pageInfo) {
+ this.pageInfo = packageFiles.pageInfo;
+ }
+ },
+ error(error) {
this.fetchPackageFilesError = true;
+ Sentry.captureException(error);
},
},
},
@@ -105,23 +117,21 @@ export default {
return {
fetchPackageFilesError: false,
filesToDelete: [],
- packageFiles: {},
+ packageFiles: [],
mutationLoading: false,
selectedReferences: [],
+ pageInfo: {},
};
},
computed: {
- files() {
- return this.packageFiles?.nodes ?? [];
- },
areFilesSelected() {
return this.selectedReferences.length > 0;
},
areAllFilesSelected() {
- return this.files.length > 0 && this.files.every(this.isSelected);
+ return this.packageFiles.length > 0 && this.packageFiles.every(this.isSelected);
},
filesTableRows() {
- return this.files.map((pf) => ({
+ return this.packageFiles.map((pf) => ({
...pf,
size: this.formatSize(pf.size),
}));
@@ -168,6 +178,10 @@ export default {
first: GRAPHQL_PACKAGE_FILES_PAGE_SIZE,
};
},
+ showPagination() {
+ const { hasPreviousPage, hasNextPage } = this.pageInfo;
+ return hasPreviousPage || hasNextPage;
+ },
tracking() {
return {
category: packageTypeToTrackCategory(this.packageType),
@@ -258,7 +272,7 @@ export default {
},
handleFileDelete(files) {
this.track(REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION);
- if (files.length === this.files.length && !this.packageFiles?.pageInfo?.hasNextPage) {
+ if (files.length === this.packageFiles.length && !this.pageInfo.hasNextPage) {
this.$emit(
'delete-all-files',
this.hasOneItem(files)
@@ -281,6 +295,41 @@ export default {
}
this.deletePackageFiles(this.filesToDelete.map((file) => file.id));
},
+ fetchPreviousFilesPage() {
+ return this.$apollo.queries.packageFiles
+ .fetchMore({
+ variables: {
+ first: null,
+ last: GRAPHQL_PACKAGE_FILES_PAGE_SIZE,
+ before: this.pageInfo.startCursor,
+ },
+ })
+ .then(() => {
+ this.scrollAndFocus();
+ });
+ },
+ fetchNextFilesPage() {
+ return this.$apollo.queries.packageFiles
+ .fetchMore({
+ variables: {
+ first: GRAPHQL_PACKAGE_FILES_PAGE_SIZE,
+ last: null,
+ after: this.pageInfo.endCursor,
+ },
+ })
+ .then(() => {
+ this.scrollAndFocus();
+ });
+ },
+ scrollAndFocus() {
+ scrollToElement(this.$el);
+
+ // get first focusable row
+ const focusable = this.$el.querySelector('tbody tr');
+ if (focusable) {
+ focusable.focus();
+ }
+ },
},
i18n: {
deleteFile: s__('PackageRegistry|Delete asset'),
@@ -295,6 +344,8 @@ export default {
deleteSelected: s__('PackageRegistry|Delete selected'),
moreActionsText: __('More actions'),
fetchPackageFilesErrorMessage: FETCH_PACKAGE_FILES_ERROR_MESSAGE,
+ prev: PREV,
+ next: NEXT,
},
modal: {
fileDeletePrimaryAction: {
@@ -334,102 +385,122 @@ export default {
>
{{ $options.i18n.fetchPackageFilesErrorMessage }}
</gl-alert>
- <gl-table
- v-else
- :busy="isLoading"
- :fields="filesTableHeaderFields"
- :items="filesTableRows"
- show-empty
- selectable
- select-mode="multi"
- selected-variant="primary"
- :tbody-tr-attr="{ 'data-testid': 'file-row' }"
- @row-selected="updateSelectedReferences"
- >
- <template #table-busy>
- <gl-loading-icon size="lg" class="gl-my-5" />
- </template>
- <template #head(checkbox)="{ selectAllRows, clearSelected }">
- <gl-form-checkbox
- v-if="canDelete"
- data-testid="package-files-checkbox-all"
- :checked="areAllFilesSelected"
- :indeterminate="hasSelectedSomeFiles"
- @change="areAllFilesSelected ? clearSelected() : selectAllRows()"
- />
- </template>
+ <template v-else>
+ <gl-table
+ ref="table"
+ :busy="isLoading"
+ :fields="filesTableHeaderFields"
+ :items="filesTableRows"
+ show-empty
+ selectable
+ select-mode="multi"
+ selected-variant="primary"
+ :tbody-tr-attr="{ 'data-testid': 'file-row' }"
+ @row-selected="updateSelectedReferences"
+ >
+ <template #table-busy>
+ <gl-loading-icon size="lg" class="gl-my-5" />
+ </template>
+ <template #head(checkbox)="{ selectAllRows, clearSelected }">
+ <gl-form-checkbox
+ v-if="canDelete"
+ data-testid="package-files-checkbox-all"
+ :checked="areAllFilesSelected"
+ :indeterminate="hasSelectedSomeFiles"
+ @change="areAllFilesSelected ? clearSelected() : selectAllRows()"
+ />
+ </template>
- <template #cell(checkbox)="{ rowSelected, selectRow, unselectRow }">
- <gl-form-checkbox
- v-if="canDelete"
- class="gl-mt-1"
- :checked="rowSelected"
- data-testid="package-files-checkbox"
- @change="rowSelected ? unselectRow() : selectRow()"
- />
- </template>
+ <template #cell(checkbox)="{ rowSelected, selectRow, unselectRow }">
+ <gl-form-checkbox
+ v-if="canDelete"
+ class="gl-mt-1"
+ :checked="rowSelected"
+ data-testid="package-files-checkbox"
+ @change="rowSelected ? unselectRow() : selectRow()"
+ />
+ </template>
- <template #cell(name)="{ item, toggleDetails, detailsShowing }">
- <gl-button
- v-if="hasDetails(item)"
- :icon="detailsShowing ? 'chevron-up' : 'chevron-down'"
- :aria-label="detailsShowing ? __('Collapse') : __('Expand')"
- category="tertiary"
- size="small"
- @click="
- toggleDetails();
- trackToggleDetails(detailsShowing);
- "
- />
- <gl-link
- :href="item.downloadPath"
- class="gl-text-gray-500"
- data-testid="download-link"
- @click="track($options.trackingActions.DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION)"
- >
- <file-icon
- :file-name="item.fileName"
- css-classes="gl-relative file-icon"
- class="gl-mr-1 gl-relative"
+ <template #cell(name)="{ item, toggleDetails, detailsShowing }">
+ <gl-button
+ v-if="hasDetails(item)"
+ :icon="detailsShowing ? 'chevron-up' : 'chevron-down'"
+ :aria-label="detailsShowing ? __('Collapse') : __('Expand')"
+ data-testid="toggle-details-button"
+ category="tertiary"
+ size="small"
+ @click="
+ toggleDetails();
+ trackToggleDetails(detailsShowing);
+ "
/>
- <span>{{ item.fileName }}</span>
- </gl-link>
- </template>
+ <gl-link
+ :href="item.downloadPath"
+ class="gl-text-gray-500"
+ data-testid="download-link"
+ @click="track($options.trackingActions.DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION)"
+ >
+ <file-icon
+ :file-name="item.fileName"
+ css-classes="gl-relative file-icon"
+ class="gl-mr-1 gl-relative"
+ />
+ <span>{{ item.fileName }}</span>
+ </gl-link>
+ </template>
- <template #cell(created)="{ item }">
- <time-ago-tooltip :time="item.createdAt" />
- </template>
+ <template #cell(created)="{ item }">
+ <time-ago-tooltip :time="item.createdAt" />
+ </template>
- <template #cell(actions)="{ item }">
- <gl-dropdown
- category="tertiary"
- icon="ellipsis_v"
- :text-sr-only="true"
- :text="$options.i18n.moreActionsText"
- no-caret
- right
- >
- <gl-dropdown-item data-testid="delete-file" @click="handleFileDelete([item])">
- {{ $options.i18n.deleteFile }}
- </gl-dropdown-item>
- </gl-dropdown>
- </template>
+ <template #cell(actions)="{ item }">
+ <gl-disclosure-dropdown
+ category="tertiary"
+ icon="ellipsis_v"
+ placement="right"
+ :toggle-text="$options.i18n.moreActionsText"
+ text-sr-only
+ no-caret
+ >
+ <gl-disclosure-dropdown-item
+ data-testid="delete-file"
+ @action="handleFileDelete([item])"
+ >
+ <template #list-item>
+ {{ $options.i18n.deleteFile }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown>
+ </template>
- <template #row-details="{ item }">
- <div
- class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-bg-gray-10 gl-rounded-base gl-inset-border-1-gray-100"
- >
- <file-sha
- v-if="item.fileSha256"
- data-testid="sha-256"
- title="SHA-256"
- :sha="item.fileSha256"
- />
- <file-sha v-if="item.fileMd5" data-testid="md5" title="MD5" :sha="item.fileMd5" />
- <file-sha v-if="item.fileSha1" data-testid="sha-1" title="SHA-1" :sha="item.fileSha1" />
- </div>
- </template>
- </gl-table>
+ <template #row-details="{ item }">
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-bg-gray-10 gl-rounded-base gl-inset-border-1-gray-100"
+ >
+ <file-sha
+ v-if="item.fileSha256"
+ data-testid="sha-256"
+ title="SHA-256"
+ :sha="item.fileSha256"
+ />
+ <file-sha v-if="item.fileMd5" data-testid="md5" title="MD5" :sha="item.fileMd5" />
+ <file-sha v-if="item.fileSha1" data-testid="sha-1" title="SHA-1" :sha="item.fileSha1" />
+ </div>
+ </template>
+ </gl-table>
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-keyset-pagination
+ v-if="showPagination"
+ :disabled="isLoading"
+ v-bind="pageInfo"
+ :prev-text="$options.i18n.prev"
+ :next-text="$options.i18n.next"
+ class="gl-mt-3"
+ @prev="fetchPreviousFilesPage"
+ @next="fetchNextFilesPage"
+ />
+ </div>
+ </template>
<gl-modal
ref="deleteFilesModal"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
index 37a6fe75f15..ca2516810cf 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
@@ -1,7 +1,6 @@
<script>
import {
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
GlFormCheckbox,
GlIcon,
GlLink,
@@ -25,8 +24,7 @@ import {
export default {
name: 'PackageVersionRow',
components: {
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
GlFormCheckbox,
GlIcon,
GlLink,
@@ -61,6 +59,18 @@ export default {
errorStatusRow() {
return this.packageEntity?.status === PACKAGE_ERROR_STATUS;
},
+ dropdownItems() {
+ return [
+ {
+ text: this.$options.i18n.deletePackage,
+ action: () => this.$emit('delete'),
+ extraAttrs: {
+ class: 'gl-text-red-500!',
+ 'data-testid': 'action-delete',
+ },
+ },
+ ];
+ },
},
i18n: {
deletePackage: DELETE_PACKAGE_TEXT,
@@ -129,18 +139,15 @@ export default {
</template>
<template v-if="packageEntity.canDestroy" #right-action>
- <gl-dropdown
+ <gl-disclosure-dropdown
data-testid="delete-dropdown"
icon="ellipsis_v"
- :text="$options.i18n.moreActions"
- :text-sr-only="true"
+ :items="dropdownItems"
+ :toggle-text="$options.i18n.moreActions"
category="tertiary"
+ text-sr-only
no-caret
- >
- <gl-dropdown-item data-testid="action-delete" variant="danger" @click="$emit('delete')">{{
- $options.i18n.deletePackage
- }}</gl-dropdown-item>
- </gl-dropdown>
+ />
</template>
</list-item>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
index 4ec83a869b3..c690e8fac43 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
@@ -7,8 +7,9 @@ import {
GlSprintf,
GlTooltipDirective,
GlTruncate,
+ GlLink,
} from '@gitlab/ui';
-import { __ } from '~/locale';
+import { s__, __ } from '~/locale';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import {
DELETE_PACKAGE_TEXT,
@@ -19,7 +20,6 @@ import {
WARNING_TEXT,
} from '~/packages_and_registries/package_registry/constants';
import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/utils';
-import PackagePath from '~/packages_and_registries/shared/components/package_path.vue';
import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue';
import PublishMethod from '~/packages_and_registries/package_registry/components/list/publish_method.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -34,8 +34,8 @@ export default {
GlIcon,
GlSprintf,
GlTruncate,
+ GlLink,
PackageTags,
- PackagePath,
PublishMethod,
ListItem,
TimeagoTooltip,
@@ -43,7 +43,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: ['isGroupPage'],
+ inject: ['isGroupPage', 'canDeletePackages'],
props: {
packageEntity: {
type: Object,
@@ -68,9 +68,29 @@ export default {
pipeline() {
return this.packageEntity?.pipelines?.nodes[0];
},
+ projectName() {
+ return this.packageEntity.project.name;
+ },
+ projectLink() {
+ return this.packageEntity.project.webUrl;
+ },
pipelineUser() {
return this.pipeline?.user?.name;
},
+ publishedMessage() {
+ if (this.isGroupPage) {
+ if (this.pipelineUser) {
+ return s__(`PackageRegistry|Published to %{projectName} by %{author}, %{date}`);
+ }
+ return s__(`PackageRegistry|Published to %{projectName}, %{date}`);
+ }
+
+ if (this.pipelineUser) {
+ return s__(`PackageRegistry|Published by %{author}, %{date}`);
+ }
+
+ return s__(`PackageRegistry|Published %{date}`);
+ },
errorStatusRow() {
return this.packageEntity.status === PACKAGE_ERROR_STATUS;
},
@@ -102,7 +122,7 @@ export default {
<list-item data-testid="package-row" :selected="selected" v-bind="$attrs">
<template #left-action>
<gl-form-checkbox
- v-if="packageEntity.canDestroy"
+ v-if="canDeletePackages"
class="gl-m-0"
:checked="selected"
@change="$emit('select')"
@@ -142,20 +162,7 @@ export default {
:text="packageEntity.version"
:with-tooltip="true"
/>
-
- <div v-if="pipelineUser" class="gl-display-none gl-sm-display-flex gl-ml-2">
- <gl-sprintf :message="s__('PackageRegistry|published by %{author}')">
- <template #author>{{ pipelineUser }}</template>
- </gl-sprintf>
- </div>
-
<span class="gl-ml-2" data-testid="package-type">&middot; {{ packageType }}</span>
-
- <package-path
- v-if="isGroupPage"
- :path="packageEntity.project.fullPath"
- :disabled="nonDefaultRow"
- />
</div>
<div v-else>
<gl-icon
@@ -174,11 +181,15 @@ export default {
</template>
<template #right-secondary>
- <span data-testid="created-date">
- <gl-sprintf :message="$options.i18n.createdAt">
- <template #timestamp>
+ <span data-testid="right-secondary">
+ <gl-sprintf :message="publishedMessage">
+ <template v-if="isGroupPage" #projectName>
+ <gl-link data-testid="root-link" :href="projectLink">{{ projectName }}</gl-link>
+ </template>
+ <template #date>
<timeago-tooltip :time="packageEntity.createdAt" />
</template>
+ <template v-if="pipelineUser" #author>{{ pipelineUser }}</template>
</gl-sprintf>
</span>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
index effed4891d8..a7831ef2588 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
@@ -36,6 +36,7 @@ export default {
RegistryList,
},
mixins: [Tracking.mixin()],
+ inject: ['canDeletePackages'],
props: {
list: {
type: Array,
@@ -175,6 +176,7 @@ export default {
>
<registry-list
data-testid="packages-table"
+ :hidden-delete="!canDeletePackages"
:is-loading="isLoading"
:items="list"
:pagination="pageInfo"
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 80712c2991c..364bd430f07 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -235,4 +235,4 @@ export const REQUEST_FORWARDING_HELP_PAGE_PATH = helpPagePath(
);
export const GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE = 10;
-export const GRAPHQL_PACKAGE_FILES_PAGE_SIZE = 100;
+export const GRAPHQL_PACKAGE_FILES_PAGE_SIZE = 20;
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql
index bcd90b7bee5..0c8af248c43 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql
@@ -26,6 +26,7 @@ fragment PackageData on Package {
}
project {
id
+ name
fullPath
webUrl
}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
index d05ff5daad4..d630e040d52 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
@@ -22,6 +22,7 @@ export const apolloProvider = new VueApollo({
merge: mergeVariables,
},
packageFiles: {
+ keyArgs: ['id'],
merge: mergeVariables,
},
},
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql
index e6f292ec1d3..7a389b2aa5c 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql
@@ -1,9 +1,17 @@
-query getPackageFiles($id: PackagesPackageID!, $first: Int) {
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+
+query getPackageFiles(
+ $id: PackagesPackageID!
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
+) {
package(id: $id) {
id
- packageFiles(first: $first) {
+ packageFiles(after: $after, before: $before, first: $first, last: $last) {
pageInfo {
- hasNextPage
+ ...PageInfo
}
nodes {
id
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/index.js b/app/assets/javascripts/packages_and_registries/package_registry/index.js
index e2f8d239bae..ae0f6d18d99 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/index.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
+import { parseBoolean } from '~/lib/utils/common_utils';
import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index';
import PackageRegistry from '~/packages_and_registries/package_registry/pages/index.vue';
import RegistryBreadcrumb from '~/packages_and_registries/shared/components/registry_breadcrumb.vue';
@@ -20,6 +21,7 @@ export default () => {
projectListUrl,
groupListUrl,
settingsPath,
+ canDeletePackages,
} = el.dataset;
const isGroupPage = pageType === 'groups';
@@ -50,6 +52,7 @@ export default () => {
groupListUrl,
breadCrumbState,
settingsPath,
+ canDeletePackages: parseBoolean(canDeletePackages),
},
render(createElement) {
return createElement(PackageRegistry);
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 14d617a7a3c..486c3ef31c5 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
@@ -2,6 +2,7 @@
import { GlButton, GlEmptyState, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { createAlert, VARIANT_INFO } from '~/alert';
import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
+import { fetchPolicies } from '~/lib/graphql';
import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants';
@@ -38,11 +39,13 @@ export default {
sort: '',
filters: {},
mutationLoading: false,
+ pageParams: {},
};
},
apollo: {
packagesResource: {
query: getPackagesQuery,
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
variables() {
return this.queryVariables;
},
@@ -72,6 +75,7 @@ export default {
packageName: this.filters?.packageName,
packageType: this.filters?.packageType,
first: GRAPHQL_PAGE_SIZE,
+ ...this.pageParams,
};
},
graphqlResource() {
@@ -120,37 +124,22 @@ export default {
}
},
handleSearchUpdate({ sort, filters }) {
+ this.pageParams = {};
this.sort = sort;
this.filters = { ...filters };
},
- updateQuery(_, { fetchMoreResult }) {
- return fetchMoreResult;
- },
fetchNextPage() {
- const variables = {
- ...this.queryVariables,
+ this.pageParams = {
first: GRAPHQL_PAGE_SIZE,
- last: null,
after: this.pageInfo?.endCursor,
};
-
- this.$apollo.queries.packagesResource.fetchMore({
- variables,
- updateQuery: this.updateQuery,
- });
},
fetchPreviousPage() {
- const variables = {
- ...this.queryVariables,
+ this.pageParams = {
first: null,
last: GRAPHQL_PAGE_SIZE,
before: this.pageInfo?.startCursor,
};
-
- this.$apollo.queries.packagesResource.fetchMore({
- variables,
- updateQuery: this.updateQuery,
- });
},
},
i18n: {
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
index 4c25c0f97de..6ff7d58fd9e 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
@@ -78,6 +78,7 @@ export default {
</gl-alert>
<packages-settings
+ class="settings-section-no-bottom"
:package-settings="packageSettings"
:is-loading="isLoading"
@success="handleSuccess(2)"
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
index 80df8ef81e6..e9b72651c67 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
@@ -147,7 +147,7 @@ export default {
<p v-if="value.nextRunAt" data-testid="next-run-at">
{{ nextCleanupMessage }}
</p>
- <div class="gl-mt-7 gl-display-flex gl-align-items-center">
+ <div class="gl-mt-6 gl-display-flex gl-align-items-center">
<gl-button
data-testid="save-button"
type="submit"
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue
index 7740924b058..1a63252a850 100644
--- a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue
@@ -1,17 +1,15 @@
<template>
- <section class="settings gl-py-7">
- <div class="row">
- <div class="col-lg-4">
- <h4>
+ <section class="settings-section">
+ <div class="settings-sticky-header">
+ <div class="settings-sticky-header-inner">
+ <h4 class="gl-my-0">
<slot name="title"></slot>
</h4>
- <p>
- <slot name="description"></slot>
- </p>
- </div>
- <div class="col-lg-8 gl-pt-3">
- <slot></slot>
</div>
</div>
+ <p class="gl-text-secondary">
+ <slot name="description"></slot>
+ </p>
+ <slot></slot>
</section>
</template>
diff --git a/app/assets/javascripts/pages/groups/new/components/app.vue b/app/assets/javascripts/pages/groups/new/components/app.vue
index 3ee15077d00..876e85e4a47 100644
--- a/app/assets/javascripts/pages/groups/new/components/app.vue
+++ b/app/assets/javascripts/pages/groups/new/components/app.vue
@@ -1,6 +1,6 @@
<script>
-import importGroupIllustration from '@gitlab/svgs/dist/illustrations/group-import.svg?raw';
-import newGroupIllustration from '@gitlab/svgs/dist/illustrations/group-new.svg?raw';
+import GROUP_IMPORT_SVG_URL from '@gitlab/svgs/dist/illustrations/group-import.svg?url';
+import GROUP_NEW_SVG_URL from '@gitlab/svgs/dist/illustrations/group-new.svg?url';
import { s__ } from '~/locale';
import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue';
@@ -69,12 +69,12 @@ export default {
description: s__(
'GroupsNew|Assemble related projects together and grant members access to several projects at once.',
),
- illustration: newGroupIllustration,
details: createGroupDescriptionDetails,
detailProps: {
parentGroupName: this.parentGroupName,
importExistingGroupPath: this.importExistingGroupPath,
},
+ imageSrc: GROUP_NEW_SVG_URL,
},
{
name: 'import-group-pane',
@@ -83,8 +83,8 @@ export default {
description: s__(
'GroupsNew|Import a group and related data from another GitLab instance.',
),
- illustration: importGroupIllustration,
details: 'Migrate your existing groups from another instance of GitLab.',
+ imageSrc: GROUP_IMPORT_SVG_URL,
},
];
},
diff --git a/app/assets/javascripts/pages/organizations/organizations/groups_and_projects/index.js b/app/assets/javascripts/pages/organizations/organizations/groups_and_projects/index.js
new file mode 100644
index 00000000000..50afa5a75ae
--- /dev/null
+++ b/app/assets/javascripts/pages/organizations/organizations/groups_and_projects/index.js
@@ -0,0 +1,3 @@
+import { initOrganizationsGroupsAndProjects } from '~/organizations/groups_and_projects';
+
+initOrganizationsGroupsAndProjects();
diff --git a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
index ea6bca644ed..8fe822e4639 100644
--- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
+++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
@@ -7,7 +7,9 @@ const twoFactorNode = document.querySelector('.js-two-factor-auth');
const skippable = twoFactorNode ? parseBoolean(twoFactorNode.dataset.twoFactorSkippable) : false;
if (skippable) {
- const button = `<br/><a class="btn gl-button btn-sm btn-confirm gl-mt-3" data-qa-selector="configure_it_later_button" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a>`;
+ const button = `<div class="gl-alert-actions">
+ <a class="btn gl-button btn-md btn-confirm gl-alert-action" data-qa-selector="configure_it_later_button" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a>
+ </div>`;
const flashAlert = document.querySelector('.flash-alert');
if (flashAlert) {
// eslint-disable-next-line no-unsanitized/method
@@ -17,7 +19,5 @@ if (skippable) {
mount2faRegistration();
initWebAuthnRegistration();
-
initRecoveryCodes();
-
initManageTwoFactorForm();
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index f8dcf1a5c9c..f5e09d972a9 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -2,6 +2,7 @@ import Vue from 'vue';
import Vuex from 'vuex';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
+import { provideWebIdeLink } from 'ee_else_ce/pages/projects/shared/web_ide_link/provide_web_ide_link';
import TableOfContents from '~/blob/components/table_contents.vue';
import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue';
import { BlobViewer, initAuxiliaryViewer } from '~/blob/viewer/index';
@@ -10,7 +11,7 @@ import createDefaultClient from '~/lib/graphql';
import initBlob from '~/pages/projects/init_blob';
import ForkInfo from '~/repository/components/fork_info.vue';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
-import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
+import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status.vue';
import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
import '~/sourcegraph/load';
import createStore from '~/code_navigation/store';
@@ -18,6 +19,7 @@ import { generateRefDestinationPath } from '~/repository/utils/ref_switcher_util
import RefSelector from '~/ref/components/ref_selector.vue';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
import { parseBoolean } from '~/lib/utils/common_utils';
+import HighlightWorker from '~/vue_shared/components/source_viewer/workers/highlight_worker?worker';
Vue.use(Vuex);
Vue.use(VueApollo);
@@ -69,6 +71,7 @@ if (viewBlobEl) {
resourceId,
userId,
explainCodeAvailable,
+ ...dataset
} = viewBlobEl.dataset;
// eslint-disable-next-line no-new
@@ -78,11 +81,13 @@ if (viewBlobEl) {
router,
apolloProvider,
provide: {
+ highlightWorker: gon.features.highlightJsWorker ? new HighlightWorker() : null,
targetBranch,
originalBranch,
resourceId,
userId,
explainCodeAvailable: parseBoolean(explainCodeAvailable),
+ ...provideWebIdeLink(dataset),
},
render(createElement) {
return createElement(BlobContentViewer, {
diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js
deleted file mode 100644
index 606439866ea..00000000000
--- a/app/assets/javascripts/pages/projects/environments/metrics/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import monitoringApp from '~/monitoring/monitoring_app';
-
-monitoringApp();
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 9f7a7b436df..9659c927fbf 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
@@ -262,7 +262,6 @@ export default {
try {
const { data } = await axios.post(url, postParams);
redirectTo(data.web_url); // eslint-disable-line import/no-deprecated
- return;
} catch (error) {
createAlert({
message: s__(
diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js
index c5b63b74c35..2911069a967 100644
--- a/app/assets/javascripts/pages/projects/issues/new/index.js
+++ b/app/assets/javascripts/pages/projects/issues/new/index.js
@@ -1,5 +1,5 @@
import { initForm } from 'ee_else_ce/issues';
-import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
+import { mountMarkdownEditor } from 'ee_else_ce/vue_shared/components/markdown/mount_markdown_editor';
import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
initForm();
diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js
index 7dd128fedb9..0844e322de2 100644
--- a/app/assets/javascripts/pages/projects/issues/service_desk/index.js
+++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js
@@ -1,3 +1,6 @@
import { initFilteredSearchServiceDesk } from '~/issues';
+import { mountServiceDeskListApp } from '~/service_desk';
initFilteredSearchServiceDesk();
+
+mountServiceDeskListApp();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
index 3d81e77f879..f71a1041068 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
@@ -1,9 +1,11 @@
import Vue from 'vue';
+
+import { mountMarkdownEditor } from 'ee_else_ce/vue_shared/components/markdown/mount_markdown_editor';
+
import initPipelines from '~/commit/pipelines/pipelines_bundle';
import MergeRequest from '~/merge_request';
import CompareApp from '~/merge_requests/components/compare_app.vue';
import { __ } from '~/locale';
-import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare');
diff --git a/app/assets/javascripts/pages/projects/merge_requests/diffs/index.js b/app/assets/javascripts/pages/projects/merge_requests/diffs/index.js
index 77294c0fb9e..b15e9a14b6a 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/diffs/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/diffs/index.js
@@ -1,5 +1,5 @@
import initDiffsApp from '~/diffs';
-import { initMrPage } from '../page';
+import { initMrPage } from 'ee_else_ce/pages/projects/merge_requests/page';
initMrPage();
initDiffsApp();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
index 6127adc3584..79d771ab993 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
@@ -1,7 +1,8 @@
+import { mountMarkdownEditor } from 'ee_else_ce/vue_shared/components/markdown/mount_markdown_editor';
+
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
-import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
import { GitLabDropdown } from '~/deprecated_jquery_dropdown/gl_dropdown';
import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request';
diff --git a/app/assets/javascripts/pages/projects/merge_requests/page.js b/app/assets/javascripts/pages/projects/merge_requests/page.js
index 552e75da9b8..75e308e706f 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/page.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/page.js
@@ -9,6 +9,7 @@ import store from '~/mr_notes/stores';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
import { apolloProvider } from '~/graphql_shared/issuable_client';
import { parseBoolean } from '~/lib/utils/common_utils';
+import { initMrMoreDropdown } from '~/mr_more_dropdown';
import initShow from './init_merge_request_show';
import getStateQuery from './queries/get_state.query.graphql';
@@ -17,6 +18,7 @@ Vue.use(VueApollo);
export function initMrPage() {
initMrNotes();
initShow();
+ initMrMoreDropdown();
startCodeReviewMessaging({ signalBus: diffsEventHub });
}
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 67dc3782a24..9eaf490abb2 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -1,9 +1,5 @@
import mountNotesApp from 'ee_else_ce/mr_notes/mount_app';
-import { initReportAbuse } from '~/projects/report_abuse';
-import { initMrMoreDropdown } from '~/mr_more_dropdown';
-import { initMrPage } from '../page';
+import { initMrPage } from 'ee_else_ce/pages/projects/merge_requests/page';
initMrPage();
mountNotesApp();
-initReportAbuse();
-initMrMoreDropdown();
diff --git a/app/assets/javascripts/pages/projects/metrics_dashboard/index.js b/app/assets/javascripts/pages/projects/metrics_dashboard/index.js
deleted file mode 100644
index 606439866ea..00000000000
--- a/app/assets/javascripts/pages/projects/metrics_dashboard/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import monitoringApp from '~/monitoring/monitoring_app';
-
-monitoringApp();
diff --git a/app/assets/javascripts/pages/projects/ml/models/index/index.js b/app/assets/javascripts/pages/projects/ml/models/index/index.js
new file mode 100644
index 00000000000..62d326f43a5
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/ml/models/index/index.js
@@ -0,0 +1,4 @@
+import { initSimpleApp } from '~/helpers/init_simple_app_helper';
+import MlModelsIndex from '~/ml/model_registry/routes/models/index';
+
+initSimpleApp('#js-index-ml-models', MlModelsIndex);
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js
index e2a782bc5d8..a51c2e9c47b 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js
@@ -2,7 +2,7 @@ import initPipelineSchedulesFormApp from '~/ci/pipeline_schedules/mount_pipeline
import initForm from '../shared/init_form';
if (gon.features?.pipelineSchedulesVue) {
- initPipelineSchedulesFormApp('#pipeline-schedules-form-edit');
+ initPipelineSchedulesFormApp('#pipeline-schedules-form-edit', true);
} else {
initForm();
}
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 eab4be4dcf1..a79f20d596c 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
@@ -10,6 +10,7 @@ import {
import { getWeekdayNames } from '~/lib/utils/datetime_utility';
import { __, s__, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility';
const KEY_EVERY_DAY = 'everyDay';
const KEY_EVERY_WEEK = 'everyWeek';
@@ -54,7 +55,7 @@ export default {
inputNameAttribute: 'schedule[cron]',
radioValue: this.initialCronInterval ? KEY_CUSTOM : KEY_EVERY_DAY,
cronInterval: this.initialCronInterval,
- cronSyntaxUrl: 'https://docs.gitlab.com/ee/topics/cron/',
+ cronSyntaxUrl: `${DOCS_URL_IN_EE_DIR}/topics/cron/`,
};
},
computed: {
@@ -116,7 +117,7 @@ export default {
},
},
watch: {
- cronInterval() {
+ cronInterval(val) {
// updates field validation state when model changes, as
// glFieldError only updates on input.
if (this.sendNativeErrors) {
@@ -124,6 +125,8 @@ export default {
gl.pipelineScheduleFieldErrors.updateFormValidityState();
});
}
+
+ this.$emit('cronValue', val);
},
radioValue: {
immediate: true,
diff --git a/app/assets/javascripts/pages/projects/pipelines/show/index.js b/app/assets/javascripts/pages/projects/pipelines/show/index.js
index 44a384f03c6..d3f46b7e025 100644
--- a/app/assets/javascripts/pages/projects/pipelines/show/index.js
+++ b/app/assets/javascripts/pages/projects/pipelines/show/index.js
@@ -2,4 +2,4 @@ import initPipelineDetails from '~/pipelines/pipeline_details_bundle';
import initPipelines from '../init_pipelines';
initPipelines();
-initPipelineDetails(gon.features.pipelineDetailsHeaderVue);
+initPipelineDetails();
diff --git a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js
index ce36ff6a230..8ceea37b701 100644
--- a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js
+++ b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js
@@ -1,8 +1,9 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { provideWebIdeLink } from 'ee_else_ce/pages/projects/shared/web_ide_link/provide_web_ide_link';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { joinPaths, webIDEUrl } from '~/lib/utils/url_utility';
-import WebIdeButton from '~/vue_shared/components/web_ide_link.vue';
+import WebIdeButton from 'ee_else_ce/vue_shared/components/web_ide_link.vue';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
@@ -26,6 +27,7 @@ export default ({ el, router }) => {
apolloProvider,
provide: {
projectPath,
+ ...provideWebIdeLink(options),
},
render(h) {
return h(WebIdeButton, {
@@ -37,6 +39,7 @@ export default ({ el, router }) => {
: webIDEUrl(
joinPaths('/', projectPath, 'edit', ref, '-', this.$route?.params.path || '', '/'),
),
+ projectPath,
...options,
},
});
diff --git a/app/assets/javascripts/pages/projects/shared/web_ide_link/provide_web_ide_link.js b/app/assets/javascripts/pages/projects/shared/web_ide_link/provide_web_ide_link.js
new file mode 100644
index 00000000000..7c64bb6572e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/web_ide_link/provide_web_ide_link.js
@@ -0,0 +1,9 @@
+/**
+ * Inspects an object and extracts properties
+ * that are relevant to the web_ide_link.vue
+ * component.
+ *
+ * @returns An object with properties that are
+ * relevant to the web_ide_link.vue component. See EE version.
+ */
+export const provideWebIdeLink = () => ({});
diff --git a/app/assets/javascripts/pages/projects/tracing/index/index.js b/app/assets/javascripts/pages/projects/tracing/index/index.js
new file mode 100644
index 00000000000..64ca303f8ba
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tracing/index/index.js
@@ -0,0 +1,4 @@
+import { initSimpleApp } from '~/helpers/init_simple_app_helper';
+import ListIndex from '~/tracing/list_index.vue';
+
+initSimpleApp('#js-tracing', ListIndex);
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
index 4f68c7984e8..5bc630c61cb 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -15,8 +15,8 @@ import { setUrlFragment } from '~/lib/utils/url_utility';
import { s__, sprintf } from '~/locale';
import Tracking from '~/tracking';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking';
import {
- SAVED_USING_CONTENT_EDITOR_ACTION,
WIKI_CONTENT_EDITOR_TRACKING_LABEL,
WIKI_FORMAT_LABEL,
WIKI_FORMAT_UPDATED_ACTION,
@@ -257,9 +257,8 @@ export default {
},
trackFormSubmit() {
- if (this.isContentEditorActive) {
- this.track(SAVED_USING_CONTENT_EDITOR_ACTION);
- }
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ trackSavedUsingEditor(this.isContentEditorActive, 'Wiki');
},
trackWikiFormat() {
diff --git a/app/assets/javascripts/pages/shared/wikis/constants.js b/app/assets/javascripts/pages/shared/wikis/constants.js
index 94d086158f1..3e685292971 100644
--- a/app/assets/javascripts/pages/shared/wikis/constants.js
+++ b/app/assets/javascripts/pages/shared/wikis/constants.js
@@ -1,5 +1,4 @@
export const CONTENT_EDITOR_LOADED_ACTION = 'content_editor_loaded';
-export const SAVED_USING_CONTENT_EDITOR_ACTION = 'saved_using_content_editor';
export const WIKI_CONTENT_EDITOR_TRACKING_LABEL = 'wiki_content_editor';
export const WIKI_FORMAT_LABEL = 'wiki_format';
export const WIKI_FORMAT_UPDATED_ACTION = 'wiki_format_updated';
diff --git a/app/assets/javascripts/pages/shared/wikis/edit.js b/app/assets/javascripts/pages/shared/wikis/edit.js
index 0044575de62..a0a7cc0b07a 100644
--- a/app/assets/javascripts/pages/shared/wikis/edit.js
+++ b/app/assets/javascripts/pages/shared/wikis/edit.js
@@ -1,5 +1,7 @@
import $ from 'jquery';
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createApolloClient from '~/lib/graphql';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import csrf from '~/lib/utils/csrf';
import Translate from '~/vue_shared/translate';
@@ -64,9 +66,14 @@ const createWikiFormApp = () => {
if (el) {
const { pageInfo, formatOptions } = el.dataset;
+ Vue.use(VueApollo);
+
+ const apolloProvider = new VueApollo({ defaultClient: createApolloClient() });
+
// eslint-disable-next-line no-new
new Vue({
el,
+ apolloProvider,
provide: {
formatOptions: JSON.parse(formatOptions),
pageInfo: convertObjectPropsToCamelCase(JSON.parse(pageInfo)),
diff --git a/app/assets/javascripts/pages/users/show/index.js b/app/assets/javascripts/pages/users/show/index.js
index 47424ec1dd3..7d612d6cc4e 100644
--- a/app/assets/javascripts/pages/users/show/index.js
+++ b/app/assets/javascripts/pages/users/show/index.js
@@ -1,3 +1,5 @@
import { initUserAchievements } from '~/profile';
+import { initUserActionsApp } from '~/users/profile/actions';
initUserAchievements();
+initUserActionsApp();
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
index 83cd64c17ed..b2cef7c37b9 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -1,8 +1,8 @@
<script>
-import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import getUserCallouts from '~/graphql_shared/queries/get_user_callouts.query.graphql';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants';
import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql';
@@ -34,6 +34,7 @@ export default {
components: {
GlAlert,
GlLoadingIcon,
+ GlSprintf,
GraphViewSelector,
LocalStorageSync,
PipelineGraph,
@@ -62,6 +63,7 @@ export default {
pipeline: null,
skipRetryModal: false,
showAlert: false,
+ showJobCountWarning: false,
showLinks: false,
};
},
@@ -104,7 +106,7 @@ export default {
},
headerPipeline: {
query: getPipelineQuery,
- // this query is already being called in header_component.vue, which shares the same cache as this component
+ // this query is already being called in pipeline_details_header.vue, which shares the same cache as this component
// the skip here is to prevent sending double network requests on page load
skip() {
return !this.canRefetchHeaderPipeline;
@@ -166,7 +168,12 @@ export default {
},
);
},
- result({ error }) {
+ result({ data, error }) {
+ const stages = data?.project?.pipeline?.stages?.nodes || [];
+
+ this.showJobCountWarning = stages.some((stage) => {
+ return stage.groups.nodes.length >= 100;
+ });
/*
If there is a successful load after a failure, clear
the failure notification to avoid confusion.
@@ -273,14 +280,38 @@ export default {
this.currentViewType = type;
},
},
+ i18n: {
+ jobLimitWarning: {
+ title: s__('Pipeline|Only the first 100 jobs per stage are displayed'),
+ desc: s__('Pipeline|To see the remaining jobs, go to the %{boldStart}Jobs%{boldEnd} tab.'),
+ },
+ },
viewTypeKey: VIEW_TYPE_KEY,
};
</script>
<template>
<div>
- <gl-alert v-if="showAlert" :variant="alert.variant" @dismiss="hideAlert">
+ <gl-alert
+ v-if="showAlert"
+ :variant="alert.variant"
+ data-testid="error-alert"
+ @dismiss="hideAlert"
+ >
{{ alert.text }}
</gl-alert>
+ <gl-alert
+ v-if="showJobCountWarning"
+ variant="warning"
+ :dismissible="false"
+ :title="$options.i18n.jobLimitWarning.title"
+ data-testid="job-count-warning"
+ >
+ <gl-sprintf :message="$options.i18n.jobLimitWarning.desc">
+ <template #bold="{ content }">
+ <b>{{ content }}</b>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
<local-storage-sync
:storage-key="$options.viewTypeKey"
:value="currentViewType"
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index 9b4e5d471d6..d8b843bdfb0 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -13,7 +13,7 @@ import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { __, sprintf } from '~/locale';
import CancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql';
import RetryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql';
-import CiStatus from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { reportToSentry } from '../../utils';
import { ACTION_FAILURE, DOWNSTREAM, UPSTREAM } from './constants';
@@ -22,7 +22,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: {
- CiStatus,
+ CiIcon,
GlBadge,
GlButton,
GlLink,
@@ -240,7 +240,7 @@ export default {
</gl-tooltip>
<div class="gl-bg-white gl-border gl-p-3 gl-rounded-lg gl-w-full" :class="cardClasses">
<div class="gl-display-flex gl-gap-x-3">
- <ci-status v-if="!pipelineIsLoading" :status="pipelineStatus" :size="24" css-classes="" />
+ <ci-icon v-if="!pipelineIsLoading" :status="pipelineStatus" :size="24" />
<div v-else class="gl-pr-3"><gl-loading-icon size="sm" inline /></div>
<div
class="gl-display-flex gl-downstream-pipeline-job-width gl-flex-direction-column gl-line-height-normal"
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
deleted file mode 100644
index 27119419060..00000000000
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ /dev/null
@@ -1,320 +0,0 @@
-<script>
-import {
- GlAlert,
- GlButton,
- GlLoadingIcon,
- GlModal,
- GlModalDirective,
- GlTooltipDirective,
-} from '@gitlab/ui';
-import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
-import { __ } from '~/locale';
-import CiHeader from '~/vue_shared/components/header_ci_component.vue';
-import {
- LOAD_FAILURE,
- POST_FAILURE,
- DELETE_FAILURE,
- DEFAULT,
- BUTTON_TOOLTIP_RETRY,
- BUTTON_TOOLTIP_CANCEL,
-} from '../constants';
-import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutation.graphql';
-import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql';
-import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql';
-import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql';
-import { getQueryHeaders } from './graph/utils';
-
-const DELETE_MODAL_ID = 'pipeline-delete-modal';
-const POLL_INTERVAL = 10000;
-
-export default {
- name: 'PipelineHeaderSection',
- BUTTON_TOOLTIP_RETRY,
- BUTTON_TOOLTIP_CANCEL,
- pipelineCancel: 'pipelineCancel',
- pipelineRetry: 'pipelineRetry',
- finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'],
- components: {
- CiHeader,
- GlAlert,
- GlButton,
- GlLoadingIcon,
- GlModal,
- },
- directives: {
- GlModal: GlModalDirective,
- GlTooltip: GlTooltipDirective,
- },
- errorTexts: {
- [LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'),
- [POST_FAILURE]: __('An error occurred while making the request.'),
- [DELETE_FAILURE]: __('An error occurred while deleting the pipeline.'),
- [DEFAULT]: __('An unknown error occurred.'),
- },
- inject: {
- graphqlResourceEtag: {
- default: '',
- },
- paths: {
- default: {},
- },
- pipelineId: {
- default: '',
- },
- pipelineIid: {
- default: '',
- },
- },
- modal: {
- id: DELETE_MODAL_ID,
- actionPrimary: {
- text: __('Delete pipeline'),
- attributes: {
- variant: 'danger',
- },
- },
- actionCancel: {
- text: __('Cancel'),
- },
- },
- apollo: {
- pipeline: {
- context() {
- return getQueryHeaders(this.graphqlResourceEtag);
- },
- query: getPipelineQuery,
- variables() {
- return {
- fullPath: this.paths.fullProject,
- iid: this.pipelineIid,
- };
- },
- update: (data) => data.project.pipeline,
- error() {
- this.reportFailure(LOAD_FAILURE);
- },
- pollInterval: POLL_INTERVAL,
- watchLoading(isLoading) {
- if (!isLoading) {
- // To ensure apollo has updated the cache,
- // we only remove the loading state in sync with GraphQL
- this.isCanceling = false;
- this.isRetrying = false;
- }
- },
- },
- },
- data() {
- return {
- pipeline: null,
- failureMessages: [],
- failureType: null,
- isCanceling: false,
- isRetrying: false,
- isDeleting: false,
- };
- },
- computed: {
- deleteModalConfirmationText() {
- return __(
- 'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.',
- );
- },
- hasError() {
- return this.failureType;
- },
- hasPipelineData() {
- return Boolean(this.pipeline);
- },
- isLoadingInitialQuery() {
- return this.$apollo.queries.pipeline.loading && !this.hasPipelineData;
- },
- status() {
- return this.pipeline?.status;
- },
- isFinished() {
- return this.$options.finishedStatuses.includes(this.status);
- },
- shouldRenderContent() {
- return !this.isLoadingInitialQuery && this.hasPipelineData;
- },
- failure() {
- switch (this.failureType) {
- case LOAD_FAILURE:
- return {
- text: this.$options.errorTexts[LOAD_FAILURE],
- variant: 'danger',
- };
- case POST_FAILURE:
- return {
- text: this.$options.errorTexts[POST_FAILURE],
- variant: 'danger',
- };
- case DELETE_FAILURE:
- return {
- text: this.$options.errorTexts[DELETE_FAILURE],
- variant: 'danger',
- };
- default:
- return {
- text: this.$options.errorTexts[DEFAULT],
- variant: 'danger',
- };
- }
- },
- canRetryPipeline() {
- const { retryable, userPermissions } = this.pipeline;
-
- return retryable && userPermissions.updatePipeline;
- },
- canCancelPipeline() {
- const { cancelable, userPermissions } = this.pipeline;
-
- return cancelable && userPermissions.updatePipeline;
- },
- },
- methods: {
- reportFailure(errorType, errorMessages = []) {
- this.failureType = errorType;
- this.failureMessages = errorMessages;
- },
- async postPipelineAction(name, mutation) {
- try {
- const {
- data: {
- [name]: { errors },
- },
- } = await this.$apollo.mutate({
- mutation,
- variables: { id: this.pipeline.id },
- });
-
- if (errors.length > 0) {
- this.isRetrying = false;
-
- this.reportFailure(POST_FAILURE, errors);
- } else {
- await this.$apollo.queries.pipeline.refetch();
- if (!this.isFinished) {
- this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL);
- }
- }
- } catch {
- this.isRetrying = false;
-
- this.reportFailure(POST_FAILURE);
- }
- },
- cancelPipeline() {
- this.isCanceling = true;
- this.postPipelineAction(this.$options.pipelineCancel, cancelPipelineMutation);
- },
- retryPipeline() {
- this.isRetrying = true;
- this.postPipelineAction(this.$options.pipelineRetry, retryPipelineMutation);
- },
- async deletePipeline() {
- this.isDeleting = true;
- this.$apollo.queries.pipeline.stopPolling();
-
- try {
- const {
- data: {
- pipelineDestroy: { errors },
- },
- } = await this.$apollo.mutate({
- mutation: deletePipelineMutation,
- variables: {
- id: this.pipeline.id,
- },
- });
-
- if (errors.length > 0) {
- this.reportFailure(DELETE_FAILURE, errors);
- this.isDeleting = false;
- } else {
- redirectTo(setUrlFragment(this.paths.pipelinesPath, 'delete_success')); // eslint-disable-line import/no-deprecated
- }
- } catch {
- this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL);
- this.reportFailure(DELETE_FAILURE);
- this.isDeleting = false;
- }
- },
- },
- DELETE_MODAL_ID,
-};
-</script>
-<template>
- <div class="js-pipeline-header-container">
- <gl-alert v-if="hasError" :title="failure.text" :variant="failure.variant" :dismissible="false">
- <div v-for="(failureMessage, index) in failureMessages" :key="`failure-message-${index}`">
- {{ failureMessage }}
- </div>
- </gl-alert>
- <ci-header
- v-if="shouldRenderContent"
- :status="pipeline.detailedStatus"
- :time="pipeline.createdAt"
- :user="pipeline.user"
- :item-id="pipelineId"
- item-name="Pipeline"
- >
- <gl-button
- v-if="canRetryPipeline"
- v-gl-tooltip
- :aria-label="$options.BUTTON_TOOLTIP_RETRY"
- :title="$options.BUTTON_TOOLTIP_RETRY"
- :loading="isRetrying"
- :disabled="isRetrying"
- variant="confirm"
- data-testid="retryPipeline"
- class="js-retry-button"
- @click="retryPipeline()"
- >
- {{ __('Retry') }}
- </gl-button>
-
- <gl-button
- v-if="canCancelPipeline"
- v-gl-tooltip
- :aria-label="$options.BUTTON_TOOLTIP_CANCEL"
- :title="$options.BUTTON_TOOLTIP_CANCEL"
- :loading="isCanceling"
- :disabled="isCanceling"
- class="gl-ml-3"
- variant="danger"
- data-testid="cancelPipeline"
- @click="cancelPipeline()"
- >
- {{ __('Cancel pipeline') }}
- </gl-button>
-
- <gl-button
- v-if="pipeline.userPermissions.destroyPipeline"
- v-gl-modal="$options.modal.id"
- :loading="isDeleting"
- :disabled="isDeleting"
- class="gl-ml-3"
- variant="danger"
- category="secondary"
- data-testid="deletePipeline"
- >
- {{ __('Delete') }}
- </gl-button>
- </ci-header>
- <gl-loading-icon v-if="isLoadingInitialQuery" size="lg" class="gl-mt-3 gl-mb-3" />
-
- <gl-modal
- :modal-id="$options.modal.id"
- :title="__('Delete pipeline')"
- :action-primary="$options.modal.actionPrimary"
- :action-cancel="$options.modal.actionCancel"
- @primary="deletePipeline()"
- >
- <p>
- {{ deleteModalConfirmationText }}
- </p>
- </gl-modal>
- </div>
-</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_details_header.vue b/app/assets/javascripts/pipelines/components/pipeline_details_header.vue
index 8fe6707028a..c53321f82bd 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_details_header.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_details_header.vue
@@ -17,6 +17,7 @@ import { __, s__, sprintf, formatNumber } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import SafeHtml from '~/vue_shared/directives/safe_html';
import {
LOAD_FAILURE,
@@ -30,7 +31,6 @@ import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutatio
import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql';
import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql';
import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql';
-import TimeAgo from './pipelines_list/time_ago.vue';
import { getQueryHeaders } from './graph/utils';
const DELETE_MODAL_ID = 'pipeline-delete-modal';
@@ -54,7 +54,7 @@ export default {
GlLoadingIcon,
GlModal,
GlSprintf,
- TimeAgo,
+ TimeAgoTooltip,
},
directives: {
GlModal: GlModalDirective,
@@ -84,12 +84,14 @@ export default {
),
stuckBadgeText: s__('Pipelines|stuck'),
stuckBadgeTooltip: s__('Pipelines|This pipeline is stuck'),
- computeCreditsTooltip: s__('Pipelines|Total amount of compute credits used for the pipeline'),
+ computeMinutesTooltip: s__('Pipelines|Total amount of compute minutes used for the pipeline'),
totalJobsTooltip: s__('Pipelines|Total number of jobs for the pipeline'),
retryPipelineText: __('Retry'),
cancelPipelineText: __('Cancel pipeline'),
deletePipelineText: __('Delete'),
clipboardTooltip: __('Copy commit SHA'),
+ createdText: s__('Pipelines|created'),
+ finishedText: s__('Pipelines|finished'),
},
errorTexts: {
[LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'),
@@ -135,7 +137,7 @@ export default {
required: false,
default: '',
},
- computeCredits: {
+ computeMinutes: {
type: String,
required: false,
default: '',
@@ -310,8 +312,8 @@ export default {
return cancelable && userPermissions.updatePipeline;
},
- showComputeCredits() {
- return this.isFinished && this.computeCredits !== '0.0';
+ showComputeMinutes() {
+ return this.isFinished && this.computeMinutes !== '0.0';
},
},
methods: {
@@ -387,7 +389,7 @@ export default {
</script>
<template>
- <div class="gl-my-4">
+ <div class="gl-my-4" data-testid="pipeline-details-header">
<gl-alert
v-if="hasError"
class="gl-mb-4"
@@ -402,17 +404,17 @@ export default {
<gl-loading-icon v-if="loading" class="gl-text-left" size="lg" />
<div
v-else
- class="gl-display-flex gl-justify-content-space-between"
+ class="gl-display-flex gl-justify-content-space-between gl-flex-wrap"
data-qa-selector="pipeline_details_header"
>
<div>
- <h3 v-if="name" class="gl-mt-0 gl-mb-2" data-testid="pipeline-name">{{ name }}</h3>
- <h3 v-else class="gl-mt-0 gl-mb-2" data-testid="pipeline-commit-title">
+ <h3 v-if="name" class="gl-mt-0 gl-mb-3" data-testid="pipeline-name">{{ name }}</h3>
+ <h3 v-else class="gl-mt-0 gl-mb-3" data-testid="pipeline-commit-title">
{{ commitTitle }}
</h3>
<div>
<ci-badge-link :status="detailedStatus" />
- <div class="gl-ml-2 gl-mb-2 gl-display-inline-block gl-h-6">
+ <div class="gl-ml-2 gl-mb-3 gl-display-inline-block gl-h-6">
<gl-link
v-if="user"
:href="user.webUrl"
@@ -441,16 +443,17 @@ export default {
:title="$options.i18n.clipboardTooltip"
size="small"
/>
- <time-ago
- v-if="isFinished"
- :pipeline="pipeline"
- class="gl-display-inline gl-mb-0"
- :display-calendar-icon="false"
- font-size="gl-font-md"
- />
+ <span v-if="inProgress" data-testid="pipeline-created-time-ago">
+ {{ $options.i18n.createdText }}
+ <time-ago-tooltip :time="pipeline.createdAt" />
+ </span>
+ <span v-if="isFinished" data-testid="pipeline-finished-time-ago">
+ {{ $options.i18n.finishedText }}
+ <time-ago-tooltip :time="pipeline.finishedAt" />
+ </span>
</div>
</div>
- <div v-safe-html="refText" class="gl-mb-2" data-testid="pipeline-ref-text"></div>
+ <div v-safe-html="refText" class="gl-mb-3" data-testid="pipeline-ref-text"></div>
<div>
<gl-badge
v-if="badges.schedule"
@@ -527,7 +530,6 @@ export default {
:title="$options.i18n.detachedBadgeTooltip"
variant="info"
size="sm"
- data-qa-selector="merge_request_badge_tag"
>
{{ $options.i18n.detachedBadgeText }}
</gl-badge>
@@ -550,14 +552,14 @@ export default {
{{ totalJobsText }}
</span>
<span
- v-if="showComputeCredits"
+ v-if="showComputeMinutes"
v-gl-tooltip
- :title="$options.i18n.computeCreditsTooltip"
+ :title="$options.i18n.computeMinutesTooltip"
class="gl-ml-2"
- data-testid="compute-credits"
+ data-testid="compute-minutes"
>
<gl-icon name="quota" />
- {{ computeCredits }}
+ {{ computeMinutes }}
</span>
<span v-if="inProgress" class="gl-ml-2" data-testid="pipeline-running-text">
<gl-icon name="timer" />
@@ -569,7 +571,7 @@ export default {
</span>
</div>
</div>
- <div>
+ <div class="gl-mt-5 gl-lg-mt-0">
<gl-button
v-if="canRetryPipeline"
v-gl-tooltip
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ios_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ios_templates.vue
index 8ff311e90e7..5208f9a3ce7 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ios_templates.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ios_templates.vue
@@ -2,7 +2,7 @@
import { GlButton, GlCard, GlSprintf, GlLink, GlPopover, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { mergeUrlParams } from '~/lib/utils/url_utility';
+import { mergeUrlParams, DOCS_URL } from '~/lib/utils/url_utility';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
import apolloProvider from '~/pipelines/graphql/provider';
import CiTemplates from './ci_templates.vue';
@@ -31,7 +31,7 @@ export default {
apolloProvider,
iOSTemplateName: 'iOS-Fastlane',
modalId: 'runner-instructions-modal',
- runnerDocsLink: 'https://docs.gitlab.com/runner/install/osx',
+ runnerDocsLink: `${DOCS_URL}/runner/install/osx`,
whatElseLink: helpPagePath('ci/index.md'),
i18n: {
title: s__('Pipelines|Get started with GitLab CI/CD'),
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue
index e40e30f2b8d..6b5e3d77b92 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue
@@ -1,16 +1,20 @@
<script>
-import { GlCollapse, GlIcon, GlLink } from '@gitlab/ui';
-import { s__, sprintf } from '~/locale';
+import { GlButton, GlCollapse, GlIcon, GlLink, GlTooltip } from '@gitlab/ui';
+import { createAlert } from '~/alert';
+import { __, s__, sprintf } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import SafeHtml from '~/vue_shared/directives/safe_html';
+import RetryMrFailedJobMutation from '../../../graphql/mutations/retry_mr_failed_job.mutation.graphql';
export default {
components: {
CiIcon,
+ GlButton,
GlCollapse,
GlIcon,
GlLink,
+ GlTooltip,
},
directives: {
SafeHtml,
@@ -23,14 +27,21 @@ export default {
},
data() {
return {
- isJobLogVisible: false,
isHovered: false,
+ isJobLogVisible: false,
+ isLoadingAction: false,
};
},
computed: {
activeClass() {
return this.isHovered ? 'gl-bg-gray-50' : '';
},
+ canReadBuild() {
+ return this.job.userPermissions.readBuild;
+ },
+ canRetryJob() {
+ return this.job.retryable && this.job.userPermissions.updateBuild;
+ },
isVisibleId() {
return `log-${this.isJobLogVisible ? 'is-visible' : 'is-hidden'}`;
},
@@ -38,7 +49,11 @@ export default {
return this.isJobLogVisible ? 'chevron-down' : 'chevron-right';
},
jobTrace() {
- return this.job?.trace?.htmlSummary || this.$options.i18n.noTraceText;
+ if (this.canReadBuild) {
+ return this.job?.trace?.htmlSummary || this.$options.i18n.noTraceText;
+ }
+
+ return this.$options.i18n.cannotReadBuild;
},
parsedJobId() {
return getIdFromGraphQLId(this.job.id);
@@ -54,18 +69,45 @@ export default {
resetActiveRow() {
this.isHovered = false;
},
- toggleJobLog(e) {
+ async retryJob() {
+ try {
+ this.isLoadingAction = true;
+
+ const {
+ data: {
+ jobRetry: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: RetryMrFailedJobMutation,
+ variables: { id: this.job.id },
+ });
+
+ if (errors.length > 0) {
+ throw new Error(errors[0]);
+ }
+
+ this.$emit('job-retried', this.job.name);
+ } catch (error) {
+ createAlert({ message: error?.message || this.$options.i18n.retryError });
+ } finally {
+ this.isLoadingAction = false;
+ }
+ },
+ toggleJobLog(event) {
// Do not toggle the log visibility when clicking on a link
- if (e.target.tagName === 'A') {
+ if (event.target.tagName === 'A') {
return;
}
-
this.isJobLogVisible = !this.isJobLogVisible;
},
},
i18n: {
+ cannotReadBuild: s__("Job|You do not have permission to read this job's log"),
+ cannotRetry: s__('Job|You do not have permission to retry this job'),
jobActionTooltipText: s__('Pipelines|Retry %{jobName} Job'),
noTraceText: s__('Job|No job log'),
+ retry: __('Retry'),
+ retryError: __('There was an error while retrying this job'),
},
};
</script>
@@ -93,6 +135,21 @@ export default {
<div class="col-2 gl-text-left">
<gl-link :href="job.webPath">#{{ parsedJobId }}</gl-link>
</div>
+ <gl-tooltip v-if="!canRetryJob" :target="() => $refs.retryBtn" placement="top">
+ {{ $options.i18n.cannotRetry }}
+ </gl-tooltip>
+ <div class="col-2 gl-text-left">
+ <span ref="retryBtn">
+ <gl-button
+ :disabled="!canRetryJob"
+ icon="retry"
+ :loading="isLoadingAction"
+ :title="$options.i18n.retry"
+ :aria-label="$options.i18n.retry"
+ @click.stop="retryJob"
+ />
+ </span>
+ </div>
</div>
<div class="row">
<gl-collapse :visible="isJobLogVisible" class="gl-w-full">
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue
new file mode 100644
index 00000000000..36687129cdd
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue
@@ -0,0 +1,166 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { createAlert } from '~/alert';
+import { __, s__, sprintf } from '~/locale';
+import { getQueryHeaders } from '~/pipelines/components/graph/utils';
+import getPipelineFailedJobs from '../../../graphql/queries/get_pipeline_failed_jobs.query.graphql';
+import { graphqlEtagPipelinePath, sortJobsByStatus } from './utils';
+import FailedJobDetails from './failed_job_details.vue';
+
+const POLL_INTERVAL = 10000;
+
+const JOB_ACTION_HEADER = __('Actions');
+const JOB_ID_HEADER = __('Job ID');
+const JOB_NAME_HEADER = __('Job name');
+const STAGE_HEADER = __('Stage');
+
+export default {
+ components: {
+ GlLoadingIcon,
+ FailedJobDetails,
+ },
+ inject: ['fullPath', 'graphqlPath'],
+ props: {
+ isPipelineActive: {
+ required: true,
+ type: Boolean,
+ },
+ pipelineIid: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ failedJobs: [],
+ isActive: false,
+ isLoadingMore: false,
+ };
+ },
+ apollo: {
+ failedJobs: {
+ context() {
+ return getQueryHeaders(this.graphqlResourceEtag);
+ },
+ query: getPipelineFailedJobs,
+ pollInterval: POLL_INTERVAL,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ pipelineIid: this.pipelineIid,
+ };
+ },
+ update(data) {
+ const jobs = data?.project?.pipeline?.jobs?.nodes || [];
+ return sortJobsByStatus(jobs);
+ },
+ result({ data }) {
+ const pipeline = data?.project?.pipeline;
+
+ if (pipeline?.jobs?.count) {
+ this.$emit('failed-jobs-count', pipeline.jobs.count);
+ this.isActive = pipeline.active;
+ }
+ },
+ error(e) {
+ createAlert({ message: e?.message || this.$options.i18n.fetchError, variant: 'danger' });
+ },
+ },
+ },
+ computed: {
+ graphqlResourceEtag() {
+ return graphqlEtagPipelinePath(this.graphqlPath, this.pipelineIid);
+ },
+ hasFailedJobs() {
+ return this.failedJobs.length > 0;
+ },
+ isInitialLoading() {
+ return this.isLoading && !this.isLoadingMore;
+ },
+ isLoading() {
+ return this.$apollo.queries.failedJobs.loading;
+ },
+ },
+ watch: {
+ isPipelineActive(flag) {
+ // Turn polling on and off based on REST actions
+ // By refetching jobs, we will get the graphql `active`
+ // field to update properly and cascade the polling changes
+ this.refetchJobs();
+ this.handlePolling(flag);
+ },
+ isActive(flag) {
+ this.handlePolling(flag);
+ },
+ },
+ mounted() {
+ if (!this.isActive && !this.isPipelineActive) {
+ this.handlePolling(false);
+ }
+ },
+ methods: {
+ handlePolling(isActive) {
+ // If the pipeline status has changed and the widget is not expanded,
+ // We start polling.
+ if (isActive) {
+ this.$apollo.queries.failedJobs.startPolling(POLL_INTERVAL);
+ } else {
+ this.$apollo.queries.failedJobs.stopPolling();
+ }
+ },
+ async retryJob(jobName) {
+ await this.refetchJobs();
+
+ this.$toast.show(sprintf(this.$options.i18n.retriedJobsSuccess, { jobName }));
+ },
+ async refetchJobs() {
+ this.isLoadingMore = true;
+
+ try {
+ await this.$apollo.queries.failedJobs.refetch();
+ } catch {
+ createAlert(this.$options.i18n.fetchError);
+ } finally {
+ this.isLoadingMore = false;
+ }
+ },
+ },
+ columns: [
+ { text: JOB_NAME_HEADER, class: 'col-6' },
+ { text: STAGE_HEADER, class: 'col-2' },
+ { text: JOB_ID_HEADER, class: 'col-2' },
+ { text: JOB_ACTION_HEADER, class: 'col-2' },
+ ],
+ i18n: {
+ fetchError: __('There was a problem fetching failed jobs'),
+ noFailedJobs: s__('Pipeline|No failed jobs in this pipeline 🎉'),
+ retriedJobsSuccess: __('%{jobName} job is being retried'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-loading-icon v-if="isInitialLoading" />
+ <div v-else-if="!hasFailedJobs">{{ $options.i18n.noFailedJobs }}</div>
+ <div v-else class="container-fluid gl-grid-tpl-rows-auto">
+ <div class="row gl-mb-6 gl-text-gray-900">
+ <div
+ v-for="col in $options.columns"
+ :key="col.text"
+ class="gl-font-weight-bold gl-text-left"
+ :class="col.class"
+ data-testid="header"
+ >
+ {{ col.text }}
+ </div>
+ </div>
+ </div>
+ <failed-job-details
+ v-for="job in failedJobs"
+ :key="job.id"
+ :job="job"
+ @job-retried="retryJob"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue
index fce0b5f525e..5e49c05f47d 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue
@@ -1,22 +1,7 @@
<script>
-import {
- GlButton,
- GlCollapse,
- GlIcon,
- GlLink,
- GlLoadingIcon,
- GlPopover,
- GlSprintf,
-} from '@gitlab/ui';
-import { createAlert } from '~/alert';
-import { __, s__ } from '~/locale';
-import getPipelineFailedJobs from '../../../graphql/queries/get_pipeline_failed_jobs.query.graphql';
-import WidgetFailedJobRow from './widget_failed_job_row.vue';
-import { sortJobsByStatus } from './utils';
-
-const JOB_ID_HEADER = __('Job ID');
-const JOB_NAME_HEADER = __('Job name');
-const STAGE_HEADER = __('Stage');
+import { GlButton, GlCollapse, GlIcon, GlLink, GlPopover, GlSprintf } from '@gitlab/ui';
+import { __, s__, sprintf } from '~/locale';
+import FailedJobsList from './failed_jobs_list.vue';
export default {
components: {
@@ -24,13 +9,20 @@ export default {
GlCollapse,
GlIcon,
GlLink,
- GlLoadingIcon,
GlPopover,
GlSprintf,
- WidgetFailedJobRow,
+ FailedJobsList,
},
inject: ['fullPath'],
props: {
+ failedJobsCount: {
+ required: true,
+ type: Number,
+ },
+ isPipelineActive: {
+ required: true,
+ type: Boolean,
+ },
pipelineIid: {
required: true,
type: Number,
@@ -42,62 +34,44 @@ export default {
},
data() {
return {
- failedJobs: [],
+ currentFailedJobsCount: this.failedJobsCount,
+ isActive: false,
isExpanded: false,
};
},
- apollo: {
- failedJobs: {
- query: getPipelineFailedJobs,
- skip() {
- return !this.isExpanded;
- },
- variables() {
- return {
- fullPath: this.fullPath,
- pipelineIid: this.pipelineIid,
- };
- },
- update(data) {
- const jobs = data?.project?.pipeline?.jobs?.nodes || [];
- return sortJobsByStatus(jobs);
- },
- error(e) {
- createAlert({ message: e?.message || this.$options.i18n.fetchError, variant: 'danger' });
- },
- },
- },
computed: {
bodyClasses() {
return this.isExpanded ? '' : 'gl-display-none';
},
- failedJobsCount() {
- return this.failedJobs.length;
+ failedJobsCountText() {
+ return sprintf(this.$options.i18n.showFailedJobs, { count: this.currentFailedJobsCount });
},
iconName() {
return this.isExpanded ? 'chevron-down' : 'chevron-right';
},
- isLoading() {
- return this.$apollo.queries.failedJobs.loading;
+ popoverId() {
+ return `popover-${this.pipelineIid}`;
+ },
+ },
+ watch: {
+ failedJobsCount(val) {
+ this.currentFailedJobsCount = val;
},
},
methods: {
+ setFailedJobsCount(count) {
+ this.currentFailedJobsCount = count;
+ },
toggleWidget() {
this.isExpanded = !this.isExpanded;
},
},
- columns: [
- { text: JOB_NAME_HEADER, class: 'col-6' },
- { text: STAGE_HEADER, class: 'col-2' },
- { text: JOB_ID_HEADER, class: 'col-2' },
- ],
i18n: {
additionalInfoPopover: s__(
'Pipelines|You will see a maximum of 100 jobs in this list. To view all failed jobs, %{linkStart}go to the details page%{linkEnd} of this pipeline.',
),
additionalInfoTitle: __('Limitation on this view'),
- fetchError: __('There was a problem fetching failed jobs'),
- showFailedJobs: __('Show failed jobs'),
+ showFailedJobs: __('Show failed jobs (%{count})'),
},
};
</script>
@@ -105,9 +79,9 @@ export default {
<div class="gl-border-none!">
<gl-button variant="link" @click="toggleWidget">
<gl-icon :name="iconName" />
- {{ $options.i18n.showFailedJobs }}
- <gl-icon id="target" name="information-o" />
- <gl-popover target="target" placement="top">
+ {{ failedJobsCountText }}
+ <gl-icon :id="popoverId" name="information-o" />
+ <gl-popover :target="popoverId" placement="top">
<template #title> {{ $options.i18n.additionalInfoTitle }} </template>
<slot>
<gl-sprintf :message="$options.i18n.additionalInfoPopover">
@@ -118,26 +92,16 @@ export default {
</slot>
</gl-popover>
</gl-button>
- <gl-loading-icon v-if="isLoading" />
<gl-collapse
- v-else
v-model="isExpanded"
class="gl-bg-gray-10 gl-border-1 gl-border-t gl-border-color-gray-100 gl-mt-4 gl-pt-3"
>
- <div class="container-fluid gl-grid-tpl-rows-auto">
- <div class="row gl-mb-6 gl-text-gray-900">
- <div
- v-for="col in $options.columns"
- :key="col.text"
- class="gl-font-weight-bold gl-text-left"
- :class="col.class"
- data-testid="header"
- >
- {{ col.text }}
- </div>
- </div>
- </div>
- <widget-failed-job-row v-for="job in failedJobs" :key="job.id" :job="job" />
+ <failed-jobs-list
+ v-if="isExpanded"
+ :is-pipeline-active="isPipelineActive"
+ :pipeline-iid="pipelineIid"
+ @failed-jobs-count="setFailedJobsCount"
+ />
</gl-collapse>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js
index 3f395fff7e0..2d0c467c54f 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js
@@ -13,3 +13,7 @@ export const sortJobsByStatus = (jobs = []) => {
return 1;
});
};
+
+export const graphqlEtagPipelinePath = (graphqlPath, pipelineId) => {
+ return `${graphqlPath}pipelines/id/${pipelineId}`;
+};
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
index 7d0cea67099..4452db64b0a 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
@@ -1,10 +1,5 @@
<script>
-import {
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlTooltipDirective,
-} from '@gitlab/ui';
+import { GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
export const i18n = {
@@ -18,9 +13,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: {
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
+ GlDisclosureDropdown,
},
inject: {
artifactsEndpoint: {
@@ -42,6 +35,21 @@ export default {
},
},
computed: {
+ items() {
+ return [
+ {
+ name: this.$options.i18n.artifactSectionHeader,
+ items: this.artifacts.map(({ name, path }) => ({
+ text: name,
+ href: path,
+ extraAttrs: {
+ download: '',
+ rel: 'nofollow',
+ },
+ })),
+ },
+ ];
+ },
shouldShowDropdown() {
return this.artifacts?.length;
},
@@ -49,31 +57,16 @@ export default {
};
</script>
<template>
- <gl-dropdown
+ <gl-disclosure-dropdown
v-if="shouldShowDropdown"
v-gl-tooltip
class="build-artifacts js-pipeline-dropdown-download"
:title="$options.i18n.artifacts"
- :text="$options.i18n.artifacts"
+ :toggle-text="$options.i18n.artifacts"
:aria-label="$options.i18n.artifacts"
icon="download"
- right
- lazy
+ placement="right"
text-sr-only
- >
- <gl-dropdown-section-header>{{
- $options.i18n.artifactSectionHeader
- }}</gl-dropdown-section-header>
-
- <gl-dropdown-item
- v-for="(artifact, i) in artifacts"
- :key="i"
- :href="artifact.path"
- rel="nofollow"
- download
- class="gl-word-break-word"
- >
- {{ artifact.name }}
- </gl-dropdown-item>
- </gl-dropdown>
+ :items="items"
+ />
</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 d884935d95b..dbb0b443235 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -71,6 +71,9 @@ export default {
};
},
computed: {
+ showFailedJobsWidget() {
+ return this.glFeatures.ciJobFailuresInMr;
+ },
tableFields() {
return [
{
@@ -143,17 +146,14 @@ export default {
const downstream = pipeline.triggered;
return keepLatestDownstreamPipelines(downstream);
},
- hasFailedJobs(pipeline) {
- return pipeline?.failed_builds?.length > 0 || false;
+ failedJobsCount(pipeline) {
+ return pipeline?.failed_builds?.length || 0;
},
setModalData(data) {
this.pipelineId = data.pipeline.id;
this.pipeline = data.pipeline;
this.endpoint = data.endpoint;
},
- showFailedJobsWidget(item) {
- return this.glFeatures.ciJobFailuresInMr && this.hasFailedJobs(item);
- },
onSubmit() {
eventHub.$emit('postAction', this.endpoint);
this.cancelingPipeline = this.pipelineId;
@@ -220,7 +220,9 @@ export default {
<template #row-details="{ item }">
<pipeline-failed-jobs-widget
- v-if="showFailedJobsWidget(item)"
+ v-if="showFailedJobsWidget"
+ :failed-jobs-count="failedJobsCount(item)"
+ :is-pipeline-active="item.active"
:pipeline-iid="item.iid"
:pipeline-path="item.path"
/>
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 bdecbb88a58..70343544638 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -14,11 +14,6 @@ export default {
type: Object,
required: true,
},
- displayCalendarIcon: {
- type: Boolean,
- required: false,
- default: true,
- },
fontSize: {
type: String,
required: false,
@@ -50,13 +45,7 @@ export default {
</p>
<p v-if="finishedTime" class="finished-at gl-display-inline-flex gl-align-items-center">
- <gl-icon
- v-if="displayCalendarIcon"
- name="calendar"
- class="gl-mr-2"
- :size="12"
- data-testid="calendar-icon"
- />
+ <gl-icon name="calendar" class="gl-mr-2" :size="12" data-testid="calendar-icon" />
<time
v-gl-tooltip
diff --git a/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue b/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue
index e9f7874d3e4..3e7827dc416 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue
@@ -43,7 +43,7 @@ export default {
};
},
testReportDocPath() {
- return helpPagePath('ci/unit_test_reports');
+ return helpPagePath('ci/testing/unit_test_reports');
},
},
};
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
index 2974bd2dd37..19318cb0c8b 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
@@ -66,7 +66,7 @@ export default {
},
wrapSymbols: ['::', '#', '.', '_', '-', '/', '\\'],
i18n,
- learnMorePath: helpPagePath('ci/unit_test_reports', {
+ learnMorePath: helpPagePath('ci/testing/unit_test_reports', {
anchor: 'viewing-unit-test-reports-on-gitlab',
}),
};
diff --git a/app/assets/javascripts/pipelines/graphql/mutations/retry_mr_failed_job.mutation.graphql b/app/assets/javascripts/pipelines/graphql/mutations/retry_mr_failed_job.mutation.graphql
new file mode 100644
index 00000000000..022d461dbec
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/mutations/retry_mr_failed_job.mutation.graphql
@@ -0,0 +1,5 @@
+mutation retryMrFailedJob($id: CiBuildID!) {
+ jobRetry(input: { id: $id }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql
index 2c842f1ac77..3d69c5e451b 100644
--- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql
@@ -3,7 +3,9 @@ query getPipelineFailedJobs($fullPath: ID!, $pipelineIid: ID!) {
id
pipeline(iid: $pipelineIid) {
id
+ active
jobs(statuses: [FAILED], retried: false, jobKind: BUILD) {
+ count
nodes {
id
allowFailure
@@ -19,6 +21,7 @@ query getPipelineFailedJobs($fullPath: ID!, $pipelineIid: ID!) {
}
name
retried
+ retryable
stage {
id
name
@@ -26,6 +29,10 @@ query getPipelineFailedJobs($fullPath: ID!, $pipelineIid: ID!) {
trace {
htmlSummary
}
+ userPermissions {
+ readBuild
+ updateBuild
+ }
webPath
}
}
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs_count.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs_count.query.graphql
new file mode 100644
index 00000000000..b70e95deab6
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs_count.query.graphql
@@ -0,0 +1,12 @@
+query getPipelineFailedJobsCount($fullPath: ID!, $pipelineIid: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ pipeline(iid: $pipelineIid) {
+ id
+ active
+ jobs(statuses: [FAILED], retried: false, jobKind: BUILD) {
+ count
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 5b9bfd53b13..f9c027539f2 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -2,20 +2,18 @@ import VueRouter from 'vue-router';
import { createAlert } from '~/alert';
import { __ } from '~/locale';
import { pipelineTabName } from './constants';
-import { createPipelineHeaderApp, createPipelineDetailsHeaderApp } from './pipeline_details_header';
+import { createPipelineDetailsHeaderApp } from './pipeline_details_header';
import { apolloProvider } from './pipeline_shared_client';
const SELECTORS = {
- PIPELINE_HEADER: '#js-pipeline-header-vue',
PIPELINE_DETAILS_HEADER: '#js-pipeline-details-header-vue',
PIPELINE_TABS: '#js-pipeline-tabs',
};
-export default async function initPipelineDetailsBundle(flagEnabled) {
- const headerSelector = flagEnabled
- ? SELECTORS.PIPELINE_DETAILS_HEADER
- : SELECTORS.PIPELINE_HEADER;
- const headerApp = flagEnabled ? createPipelineDetailsHeaderApp : createPipelineHeaderApp;
+export default async function initPipelineDetailsBundle() {
+ const headerSelector = SELECTORS.PIPELINE_DETAILS_HEADER;
+
+ const headerApp = createPipelineDetailsHeaderApp;
const headerEl = document.querySelector(headerSelector);
diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js
index 807ef225edd..c79aaef23e8 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_header.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_header.js
@@ -1,41 +1,10 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { parseBoolean } from '~/lib/utils/common_utils';
-import PipelineHeader from './components/header_component.vue';
import PipelineDetailsHeader from './components/pipeline_details_header.vue';
Vue.use(VueApollo);
-export const createPipelineHeaderApp = (elSelector, apolloProvider, graphqlResourceEtag) => {
- const el = document.querySelector(elSelector);
-
- if (!el) {
- return;
- }
-
- const { fullPath, pipelineId, pipelineIid, pipelinesPath } = el.dataset;
- // eslint-disable-next-line no-new
- new Vue({
- el,
- components: {
- PipelineHeader,
- },
- apolloProvider,
- provide: {
- paths: {
- fullProject: fullPath,
- graphqlResourceEtag,
- pipelinesPath,
- },
- pipelineId,
- pipelineIid,
- },
- render(createElement) {
- return createElement('pipeline-header', {});
- },
- });
-};
-
export const createPipelineDetailsHeaderApp = (elSelector, apolloProvider, graphqlResourceEtag) => {
const el = document.querySelector(elSelector);
@@ -49,7 +18,7 @@ export const createPipelineDetailsHeaderApp = (elSelector, apolloProvider, graph
pipelinesPath,
name,
totalJobs,
- computeCredits,
+ computeMinutes,
yamlErrors,
failureReason,
triggeredByPath,
@@ -84,7 +53,7 @@ export const createPipelineDetailsHeaderApp = (elSelector, apolloProvider, graph
props: {
name,
totalJobs,
- computeCredits,
+ computeMinutes,
yamlErrors,
failureReason,
refText,
diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue
index c64fbc91d12..915f6578ac3 100644
--- a/app/assets/javascripts/profile/account/components/delete_account_modal.vue
+++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue
@@ -1,5 +1,6 @@
<script>
import { GlModal, GlSprintf } from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import csrf from '~/lib/utils/csrf';
import { __, s__ } from '~/locale';
@@ -8,6 +9,7 @@ export default {
GlModal,
GlSprintf,
},
+ mixins: [glFeatureFlagMixin()],
props: {
actionUrl: {
type: String,
@@ -67,6 +69,9 @@ export default {
},
},
i18n: {
+ textdelay: s__(`Profiles|
+You are about to permanently delete %{yourAccount}, and all of the issues, merge requests, and groups linked to your account.
+Once you confirm %{deleteAccount}, it cannot be undone or recovered. You might have to wait seven days before creating a new account with the same username or email.`),
text: s__(`Profiles|
You are about to permanently delete %{yourAccount}, and all of the issues, merge requests, and groups linked to your account.
Once you confirm %{deleteAccount}, it cannot be undone or recovered.`),
@@ -85,7 +90,16 @@ Once you confirm %{deleteAccount}, it cannot be undone or recovered.`),
@primary="onSubmit"
>
<p>
- <gl-sprintf :message="$options.i18n.text">
+ <gl-sprintf v-if="glFeatures.delayDeleteOwnUser" :message="$options.i18n.textdelay">
+ <template #yourAccount>
+ <strong>{{ s__('Profiles|your account') }}</strong>
+ </template>
+
+ <template #deleteAccount>
+ <strong>{{ s__('Profiles|Delete account') }}</strong>
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else :message="$options.i18n.text">
<template #yourAccount>
<strong>{{ s__('Profiles|your account') }}</strong>
</template>
diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue
index d96b5748abc..ae017d2a299 100644
--- a/app/assets/javascripts/profile/account/components/update_username.vue
+++ b/app/assets/javascripts/profile/account/components/update_username.vue
@@ -115,7 +115,7 @@ Please update your Git repository remotes as soon as possible.`),
v-model="newUsername"
data-testid="new-username-input"
:disabled="isRequestPending"
- class="form-control"
+ class="form-control gl-md-form-input-lg"
required="required"
/>
</div>
diff --git a/app/assets/javascripts/profile/components/follow.vue b/app/assets/javascripts/profile/components/follow.vue
index 7bab8a1c30d..2673ab6fbf4 100644
--- a/app/assets/javascripts/profile/components/follow.vue
+++ b/app/assets/javascripts/profile/components/follow.vue
@@ -1,7 +1,14 @@
<script>
-import { GlAvatarLabeled, GlAvatarLink, GlLoadingIcon, GlPagination } from '@gitlab/ui';
+import {
+ GlAvatarLabeled,
+ GlAvatarLink,
+ GlLoadingIcon,
+ GlPagination,
+ GlEmptyState,
+} from '@gitlab/ui';
import { DEFAULT_PER_PAGE } from '~/api';
import { NEXT, PREV } from '~/vue_shared/components/pagination/constants';
+import { isCurrentUser } from '~/lib/utils/common_utils';
export default {
i18n: {
@@ -13,7 +20,9 @@ export default {
GlAvatarLink,
GlLoadingIcon,
GlPagination,
+ GlEmptyState,
},
+ inject: ['followEmptyState', 'userId'],
props: {
/**
* Expected format:
@@ -48,12 +57,34 @@ export default {
required: false,
default: DEFAULT_PER_PAGE,
},
+ currentUserEmptyStateTitle: {
+ type: String,
+ required: true,
+ },
+ visitorEmptyStateTitle: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ emptyStateTitle() {
+ return isCurrentUser(this.userId)
+ ? this.currentUserEmptyStateTitle
+ : this.visitorEmptyStateTitle;
+ },
},
};
</script>
<template>
<gl-loading-icon v-if="loading" class="gl-mt-5" size="md" />
+ <gl-empty-state
+ v-else-if="!users.length"
+ class="gl-mt-5"
+ :svg-path="followEmptyState"
+ :svg-height="144"
+ :title="emptyStateTitle"
+ />
<div v-else>
<div class="gl-my-n3 gl-mx-n3 gl-display-flex gl-flex-wrap">
<div v-for="user in users" :key="user.id" class="gl-p-3 gl-w-full gl-md-w-half gl-lg-w-25p">
diff --git a/app/assets/javascripts/profile/components/followers_tab.vue b/app/assets/javascripts/profile/components/followers_tab.vue
index 1fa579bc611..927424d6c3f 100644
--- a/app/assets/javascripts/profile/components/followers_tab.vue
+++ b/app/assets/javascripts/profile/components/followers_tab.vue
@@ -12,6 +12,8 @@ export default {
errorMessage: s__(
'UserProfile|An error occurred loading the followers. Please refresh the page to try again.',
),
+ currentUserEmptyStateTitle: s__('UserProfile|You do not have any followers'),
+ visitorEmptyStateTitle: s__("UserProfile|This user doesn't have any followers"),
},
components: {
GlBadge,
@@ -68,6 +70,8 @@ export default {
:loading="loading"
:page="page"
:total-items="totalItems"
+ :current-user-empty-state-title="$options.i18n.currentUserEmptyStateTitle"
+ :visitor-empty-state-title="$options.i18n.visitorEmptyStateTitle"
@pagination-input="onPaginationInput"
/>
</gl-tab>
diff --git a/app/assets/javascripts/profile/components/following_tab.vue b/app/assets/javascripts/profile/components/following_tab.vue
index 8ee878e3dcc..66c7ee42a3f 100644
--- a/app/assets/javascripts/profile/components/following_tab.vue
+++ b/app/assets/javascripts/profile/components/following_tab.vue
@@ -1,16 +1,62 @@
<script>
import { GlBadge, GlTab } from '@gitlab/ui';
import { s__ } from '~/locale';
+import { getUserFollowing } from '~/rest_api';
+import { createAlert } from '~/alert';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import Follow from './follow.vue';
export default {
i18n: {
title: s__('UserProfile|Following'),
+ errorMessage: s__(
+ 'UserProfile|An error occurred loading the following. Please refresh the page to try again.',
+ ),
+ currentUserEmptyStateTitle: s__('UserProfile|You are not following other users'),
+ visitorEmptyStateTitle: s__("UserProfile|This user isn't following other users"),
},
components: {
GlBadge,
GlTab,
+ Follow,
+ },
+ inject: ['followeesCount', 'userId'],
+ data() {
+ return {
+ following: [],
+ loading: true,
+ totalItems: 0,
+ page: 1,
+ };
+ },
+ watch: {
+ page: {
+ async handler() {
+ this.loading = true;
+
+ try {
+ const { data: following, headers } = await getUserFollowing(this.userId, {
+ page: this.page,
+ });
+
+ const { total } = parseIntPagination(normalizeHeaders(headers));
+
+ this.following = following;
+ this.totalItems = total;
+ } catch (error) {
+ createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true });
+ } finally {
+ this.loading = false;
+ }
+ },
+ immediate: true,
+ },
+ },
+ methods: {
+ onPaginationInput(page) {
+ this.page = page;
+ },
},
- inject: ['followeesCount'],
};
</script>
@@ -20,5 +66,14 @@ export default {
<span>{{ $options.i18n.title }}</span>
<gl-badge size="sm" class="gl-ml-2">{{ followeesCount }}</gl-badge>
</template>
+ <follow
+ :users="following"
+ :loading="loading"
+ :page="page"
+ :total-items="totalItems"
+ :current-user-empty-state-title="$options.i18n.currentUserEmptyStateTitle"
+ :visitor-empty-state-title="$options.i18n.visitorEmptyStateTitle"
+ @pagination-input="onPaginationInput"
+ />
</gl-tab>
</template>
diff --git a/app/assets/javascripts/profile/components/profile_tabs.vue b/app/assets/javascripts/profile/components/profile_tabs.vue
index 3a30c3bdc9b..e24167eb4fa 100644
--- a/app/assets/javascripts/profile/components/profile_tabs.vue
+++ b/app/assets/javascripts/profile/components/profile_tabs.vue
@@ -5,6 +5,7 @@ import { getUserProjects } from '~/rest_api';
import { s__ } from '~/locale';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { createAlert } from '~/alert';
+import { VISIBILITY_LEVEL_PUBLIC_STRING } from '~/visibility_level/constants';
import OverviewTab from './overview_tab.vue';
import ActivityTab from './activity_tab.vue';
import GroupsTab from './groups_tab.vue';
@@ -81,7 +82,21 @@ export default {
async mounted() {
try {
const response = await getUserProjects(this.userId, { per_page: 10 });
- this.personalProjects = convertObjectPropsToCamelCase(response.data, { deep: true });
+ this.personalProjects = convertObjectPropsToCamelCase(response.data, { deep: true }).map(
+ (project) => {
+ // This API does not return the `visibility` key if user is signed out.
+ // Because this API only returns public projects when signed out, in this case, we can assume
+ // the `visibility` attribute is `public` if it is missing.
+ if (!project.visibility) {
+ return {
+ ...project,
+ visibility: VISIBILITY_LEVEL_PUBLIC_STRING,
+ };
+ }
+
+ return project;
+ },
+ );
this.personalProjectsLoading = false;
} catch (error) {
createAlert({ message: this.$options.i18n.personalProjectsErrorMessage });
diff --git a/app/assets/javascripts/profile/components/snippets/snippets_tab.vue b/app/assets/javascripts/profile/components/snippets/snippets_tab.vue
index fce5e2f5e78..95649f9645b 100644
--- a/app/assets/javascripts/profile/components/snippets/snippets_tab.vue
+++ b/app/assets/javascripts/profile/components/snippets/snippets_tab.vue
@@ -1,9 +1,11 @@
<script>
import { GlTab, GlKeysetPagination, GlEmptyState } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_USER } from '~/graphql_shared/constants';
import { SNIPPET_MAX_LIST_COUNT } from '~/profile/constants';
+import { isCurrentUser } from '~/lib/utils/common_utils';
+import { helpPagePath } from '~/helpers/help_page_helper';
import getUserSnippets from '../graphql/get_user_snippets.query.graphql';
import SnippetRow from './snippet_row.vue';
@@ -11,7 +13,11 @@ export default {
name: 'SnippetsTab',
i18n: {
title: s__('UserProfile|Snippets'),
- noSnippets: s__('UserProfiles|No snippets found.'),
+ currentUserEmptyStateTitle: s__('UserProfile|Get started with snippets'),
+ visitorEmptyStateTitle: s__("UserProfile|This user doesn't have any snippets"),
+ emptyStateDescription: s__('UserProfile|Store, share, and embed bits of code and text.'),
+ newSnippet: __('New snippet'),
+ learnMore: __('Learn more'),
},
components: {
GlTab,
@@ -19,7 +25,7 @@ export default {
GlEmptyState,
SnippetRow,
},
- inject: ['userId', 'snippetsEmptyState'],
+ inject: ['userId', 'snippetsEmptyState', 'newSnippetPath'],
data() {
return {
userInfo: {},
@@ -57,6 +63,14 @@ export default {
hasSnippets() {
return this.userSnippets?.length;
},
+ emptyStateTitle() {
+ return isCurrentUser(this.userId)
+ ? this.$options.i18n.currentUserEmptyStateTitle
+ : this.$options.i18n.visitorEmptyStateTitle;
+ },
+ emptyStateDescription() {
+ return isCurrentUser(this.userId) ? this.$options.i18n.emptyStateDescription : null;
+ },
},
methods: {
isLastSnippet(index) {
@@ -76,6 +90,7 @@ export default {
beforeToken: this.pageInfo.startCursor,
};
},
+ helpPagePath,
},
};
</script>
@@ -100,11 +115,17 @@ export default {
</div>
</template>
<template v-if="!hasSnippets">
- <gl-empty-state class="gl-mt-5" :svg-height="75" :svg-path="snippetsEmptyState">
- <template #title>
- <p class="gl-font-weight-bold gl-mt-n5">{{ $options.i18n.noSnippets }}</p>
- </template>
- </gl-empty-state>
+ <gl-empty-state
+ class="gl-mt-5"
+ :svg-path="snippetsEmptyState"
+ :svg-height="144"
+ :title="emptyStateTitle"
+ :description="emptyStateDescription"
+ :primary-button-link="newSnippetPath"
+ :primary-button-text="$options.i18n.newSnippet"
+ :secondary-button-text="$options.i18n.learnMore"
+ :secondary-button-link="helpPagePath('user/snippets')"
+ />
</template>
</gl-tab>
</template>
diff --git a/app/assets/javascripts/profile/components/user_achievements.vue b/app/assets/javascripts/profile/components/user_achievements.vue
index 13a1b797a83..7ce6b61c4ac 100644
--- a/app/assets/javascripts/profile/components/user_achievements.vue
+++ b/app/assets/javascripts/profile/components/user_achievements.vue
@@ -85,7 +85,7 @@ export default {
:size="32"
tabindex="0"
shape="rect"
- class="gl-mx-2"
+ class="gl-mx-2 gl-p-1 gl-border-none"
/>
<br />
<gl-badge v-if="showCountBadge(userAchievement.count)" variant="info" size="sm">{{
diff --git a/app/assets/javascripts/profile/index.js b/app/assets/javascripts/profile/index.js
index 198ffdb434b..76430d7b34d 100644
--- a/app/assets/javascripts/profile/index.js
+++ b/app/assets/javascripts/profile/index.js
@@ -21,6 +21,8 @@ export const initProfileTabs = () => {
utcOffset,
userId,
snippetsEmptyState,
+ newSnippetPath,
+ followEmptyState,
} = el.dataset;
const apolloProvider = new VueApollo({
@@ -39,6 +41,8 @@ export const initProfileTabs = () => {
utcOffset,
userId,
snippetsEmptyState,
+ newSnippetPath,
+ followEmptyState,
},
render(createElement) {
return createElement(ProfileTabs);
diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
index 164ec46cdb9..aa30192b74b 100644
--- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
+++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
@@ -110,34 +110,33 @@ export default {
</script>
<template>
- <div class="row gl-mt-3 js-preferences-form js-search-settings-section">
- <div v-if="integrationViews.length" class="col-sm-12">
- <hr data-testid="profile-preferences-integrations-rule" />
- </div>
- <div v-if="integrationViews.length" class="col-lg-4 profile-settings-sidebar">
- <h4 class="gl-mt-0" data-testid="profile-preferences-integrations-heading">
- {{ $options.i18n.integrations }}
- </h4>
- <p>
+ <div class="gl-display-contents js-preferences-form">
+ <div
+ v-if="integrationViews.length"
+ class="settings-section gl-border-t gl-pt-6! js-search-settings-section"
+ >
+ <div class="settings-sticky-header">
+ <div class="settings-sticky-header-inner">
+ <h4 class="gl-my-0" data-testid="profile-preferences-integrations-heading">
+ {{ $options.i18n.integrations }}
+ </h4>
+ </div>
+ </div>
+ <p class="gl-text-secondary">
{{ $options.i18n.integrationsDescription }}
</p>
+ <div>
+ <integration-view
+ v-for="view in integrationViews"
+ :key="view.name"
+ :help-link="view.help_link"
+ :message="view.message"
+ :message-url="view.message_url"
+ :config="$options.integrationViewConfigs[view.name]"
+ />
+ </div>
</div>
- <div v-if="integrationViews.length" class="col-lg-8">
- <integration-view
- v-for="view in integrationViews"
- :key="view.name"
- :help-link="view.help_link"
- :message="view.message"
- :message-url="view.message_url"
- :config="$options.integrationViewConfigs[view.name]"
- />
- </div>
-
- <div class="col-lg-4"></div>
- <div class="col-lg-8">
- <hr />
- </div>
- <div class="col-sm-12 js-hide-when-nothing-matches-search">
+ <div class="settings-sticky-footer js-hide-when-nothing-matches-search">
<gl-button
category="primary"
variant="confirm"
diff --git a/app/assets/javascripts/projects/clusters_deprecation_alert/components/clusters_deprecation_alert.vue b/app/assets/javascripts/projects/clusters_deprecation_alert/components/clusters_deprecation_alert.vue
index e026b3e1060..750eb5836e1 100644
--- a/app/assets/javascripts/projects/clusters_deprecation_alert/components/clusters_deprecation_alert.vue
+++ b/app/assets/javascripts/projects/clusters_deprecation_alert/components/clusters_deprecation_alert.vue
@@ -10,6 +10,7 @@ export default {
},
inject: ['message'],
docsLink: helpPagePath('user/infrastructure/clusters/migrate_to_gitlab_agent.md'),
+ deprecationEpic: 'https://gitlab.com/groups/gitlab-org/configure/-/epics/8',
};
</script>
<template>
@@ -18,6 +19,9 @@ export default {
<template #link="{ content }">
<gl-link :href="$options.docsLink">{{ content }}</gl-link>
</template>
+ <template #deprecationLink="{ content }">
+ <gl-link :href="$options.deprecationEpic">{{ content }}</gl-link>
+ </template>
</gl-sprintf>
</gl-alert>
</template>
diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue
index 2966214e051..cf251bc7465 100644
--- a/app/assets/javascripts/projects/commits/components/author_select.vue
+++ b/app/assets/javascripts/projects/commits/components/author_select.vue
@@ -1,27 +1,18 @@
<script>
-import {
- GlDropdown,
- GlDropdownSectionHeader,
- GlDropdownItem,
- GlSearchBoxByType,
- GlDropdownDivider,
- GlTooltipDirective,
-} from '@gitlab/ui';
+import { GlAvatar, GlCollapsibleListbox, GlTooltipDirective } from '@gitlab/ui';
import { debounce } from 'lodash';
-import { mapState, mapActions } from 'vuex';
-import { redirectTo, queryToObject } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
-import { __ } from '~/locale';
+import { mapActions, mapState } from 'vuex';
+import { queryToObject, visitUrl } from '~/lib/utils/url_utility';
+import { n__, __ } from '~/locale';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
const tooltipMessage = __('Searching by both author and message is currently not supported.');
export default {
name: 'AuthorSelect',
components: {
- GlDropdown,
- GlDropdownSectionHeader,
- GlDropdownItem,
- GlSearchBoxByType,
- GlDropdownDivider,
+ GlAvatar,
+ GlCollapsibleListbox,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -35,9 +26,9 @@ export default {
data() {
return {
hasSearchParam: false,
- searchTerm: '',
- authorInput: '',
currentAuthor: '',
+ searchTerm: '',
+ searching: false,
};
},
computed: {
@@ -45,9 +36,33 @@ export default {
dropdownText() {
return this.currentAuthor || __('Author');
},
+ dropdownItems() {
+ const commitAuthorOptions = this.commitsAuthors.map((author) => ({
+ value: author.name,
+ text: author.name,
+ secondaryText: author.username,
+ avatarUrl: author.avatar_url,
+ }));
+ if (this.searchTerm) return commitAuthorOptions;
+
+ const defaultOptions = {
+ text: '',
+ options: [{ text: __('Any Author'), value: '' }],
+ textSrOnly: true,
+ };
+ const authorOptionsGroup = {
+ text: 'authors',
+ options: commitAuthorOptions,
+ textSrOnly: true,
+ };
+ return [defaultOptions, authorOptionsGroup];
+ },
tooltipTitle() {
return this.hasSearchParam && tooltipMessage;
},
+ searchSummarySrText() {
+ return n__('%d author', '%d authors', this.commitsAuthors.length);
+ },
},
mounted() {
this.fetchAuthors();
@@ -73,9 +88,7 @@ export default {
},
methods: {
...mapActions(['fetchAuthors']),
- selectAuthor(author) {
- const { name: user } = author || {};
-
+ selectAuthor(user) {
// Follow up issue "Remove usage of $.fadeIn from the codebase"
// > https://gitlab.com/gitlab-org/gitlab/-/issues/214395
@@ -89,13 +102,19 @@ export default {
commitListElement.style.transition = 'opacity 200ms';
if (!user) {
- return redirectTo(this.commitsPath); // eslint-disable-line import/no-deprecated
+ return visitUrl(this.commitsPath);
}
- return redirectTo(`${this.commitsPath}?author=${user}`); // eslint-disable-line import/no-deprecated
+ return visitUrl(`${this.commitsPath}?author=${user}`);
},
- searchAuthors() {
- this.fetchAuthors(this.authorInput);
+ searchAuthors: debounce(async function debouncedSearch() {
+ this.searching = true;
+ await this.fetchAuthors(this.searchTerm);
+ this.searching = false;
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
+ handleSearch(input) {
+ this.searchTerm = input;
+ this.searchAuthors();
},
setSearchParam(value) {
this.hasSearchParam = Boolean(value);
@@ -105,36 +124,45 @@ export default {
</script>
<template>
- <div ref="dropdownContainer" v-gl-tooltip :title="tooltipTitle" :disabled="!hasSearchParam">
- <gl-dropdown
- :text="dropdownText"
+ <div ref="listboxContainer" v-gl-tooltip :title="tooltipTitle" :disabled="!hasSearchParam">
+ <gl-collapsible-listbox
+ v-model="currentAuthor"
+ block
+ is-check-centered
+ searchable
+ class="gl-mt-3 gl-sm-mt-0"
+ :items="dropdownItems"
+ :header-text="__('Search by author')"
+ :toggle-text="dropdownText"
+ :search-placeholder="__('Search')"
+ :searching="searching"
:disabled="hasSearchParam"
- toggle-class="gl-py-3 gl-border-0"
- class="w-100 gl-mt-3 mt-sm-0"
+ @search="handleSearch"
+ @select="selectAuthor"
>
- <gl-dropdown-section-header>
- {{ __('Search by author') }}
- </gl-dropdown-section-header>
- <gl-dropdown-divider />
- <gl-search-box-by-type
- v-model.trim="authorInput"
- :placeholder="__('Search')"
- @input="searchAuthors"
- />
- <gl-dropdown-item :is-checked="!currentAuthor" @click="selectAuthor(null)">
- {{ __('Any Author') }}
- </gl-dropdown-item>
- <gl-dropdown-divider />
- <gl-dropdown-item
- v-for="author in commitsAuthors"
- :key="author.id"
- :is-checked="author.name === currentAuthor"
- :avatar-url="author.avatar_url"
- :secondary-text="author.username"
- @click="selectAuthor(author)"
- >
- {{ author.name }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <template #search-summary-sr-only>
+ {{ searchSummarySrText }}
+ </template>
+ <template #list-item="{ item }">
+ <span class="gl-display-flex gl-align-items-center">
+ <gl-avatar
+ v-if="item.avatarUrl"
+ class="gl-mr-3"
+ :size="32"
+ :entity-name="item.text"
+ :src="item.avatarUrl"
+ :alt="item.text"
+ />
+ <span
+ class="gl-display-flex gl-flex-direction-column gl-overflow-hidden gl-overflow-break-word"
+ >
+ {{ item.text }}
+ <span v-if="item.secondaryText" class="gl-text-secondary">
+ {{ item.secondaryText }}
+ </span>
+ </span>
+ </span>
+ </template>
+ </gl-collapsible-listbox>
</div>
</template>
diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue
index e4d5e5bd233..b40b28adab9 100644
--- a/app/assets/javascripts/projects/compare/components/app.vue
+++ b/app/assets/javascripts/projects/compare/components/app.vue
@@ -1,7 +1,21 @@
<script>
-import { GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui';
+import {
+ GlButton,
+ GlFormGroup,
+ GlFormRadioGroup,
+ GlIcon,
+ GlTooltipDirective,
+ GlSprintf,
+ GlLink,
+} from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
import { joinPaths } from '~/lib/utils/url_utility';
+import {
+ I18N,
+ COMPARE_OPTIONS,
+ COMPARE_REVISIONS_DOCS_URL,
+ COMPARE_OPTIONS_INPUT_NAME,
+} from '../constants';
import RevisionCard from './revision_card.vue';
export default {
@@ -9,8 +23,14 @@ export default {
components: {
RevisionCard,
GlButton,
- GlDropdown,
- GlDropdownItem,
+ GlFormRadioGroup,
+ GlFormGroup,
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
projectCompareIndexPath: {
@@ -76,24 +96,6 @@ export default {
isStraight: this.straight,
};
},
- computed: {
- straightModeDropdownItems() {
- return [
- {
- modeType: 'off',
- isEnabled: false,
- content: '..',
- testId: 'disableStraightModeButton',
- },
- {
- modeType: 'on',
- isEnabled: true,
- content: '...',
- testId: 'enableStraightModeButton',
- },
- ];
- },
- },
methods: {
onSubmit() {
this.$refs.form.submit();
@@ -110,10 +112,11 @@ export default {
onSwapRevision() {
[this.from, this.to] = [this.to, this.from]; // swaps 'from' and 'to'
},
- setStraightMode(isStraight) {
- this.isStraight = isStraight;
- },
},
+ i18n: I18N,
+ compareOptions: COMPARE_OPTIONS,
+ docsLink: COMPARE_REVISIONS_DOCS_URL,
+ inputName: COMPARE_OPTIONS_INPUT_NAME,
};
</script>
@@ -125,13 +128,26 @@ export default {
:action="projectCompareIndexPath"
>
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ <h1 class="gl-font-size-h1 gl-mt-4">{{ $options.i18n.title }}</h1>
+ <p>
+ <gl-sprintf :message="$options.i18n.subtitle">
+ <template #bold="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #link="{ content }">
+ <gl-link target="_blank" :href="$options.docsLink" data-testid="help-link">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
<div
class="gl-lg-flex-direction-row gl-lg-display-flex gl-align-items-center compare-revision-cards"
>
<revision-card
data-testid="sourceRevisionCard"
:refs-project-path="to.refsProjectPath"
- :revision-text="__('Source')"
+ :revision-text="$options.i18n.source"
params-name="to"
:params-branch="to.revision"
:projects="to.projects"
@@ -139,28 +155,26 @@ export default {
@selectProject="onSelectProject"
@selectRevision="onSelectRevision"
/>
- <div
- class="gl-display-flex gl-justify-content-center gl-align-items-center gl-align-self-end gl-my-3 gl-md-my-0 gl-pl-3 gl-pr-3"
- data-testid="ellipsis"
+ <gl-button
+ v-gl-tooltip="$options.i18n.swapRevisions"
+ class="gl-display-flex gl-mx-3 gl-align-self-end swap-button"
+ data-testid="swapRevisionsButton"
+ category="tertiary"
+ @click="onSwapRevision"
>
- <input :value="isStraight ? 'true' : 'false'" type="hidden" name="straight" />
- <gl-dropdown data-testid="modeDropdown" :text="isStraight ? '...' : '..'" size="small">
- <gl-dropdown-item
- v-for="mode in straightModeDropdownItems"
- :key="mode.modeType"
- :is-check-item="true"
- :is-checked="isStraight == mode.isEnabled"
- :data-testid="mode.testId"
- @click="setStraightMode(mode.isEnabled)"
- >
- <span class="dropdown-menu-inner-content"> {{ mode.content }} </span>
- </gl-dropdown-item>
- </gl-dropdown>
- </div>
+ <gl-icon name="substitute" />
+ </gl-button>
+ <gl-button
+ v-gl-tooltip="$options.i18n.swapRevisions"
+ class="gl-display-none gl-align-self-end gl-my-5 swap-button-mobile"
+ @click="onSwapRevision"
+ >
+ {{ $options.i18n.swap }}
+ </gl-button>
<revision-card
data-testid="targetRevisionCard"
:refs-project-path="from.refsProjectPath"
- :revision-text="__('Target')"
+ :revision-text="$options.i18n.target"
params-name="from"
:params-branch="from.revision"
:projects="from.projects"
@@ -169,22 +183,32 @@ export default {
@selectRevision="onSelectRevision"
/>
</div>
- <div class="gl-display-flex gl-mt-6 gl-gap-3">
- <gl-button category="primary" variant="confirm" @click="onSubmit">
- {{ s__('CompareRevisions|Compare') }}
- </gl-button>
- <gl-button data-testid="swapRevisionsButton" @click="onSwapRevision">
- {{ s__('CompareRevisions|Swap revisions') }}
+ <gl-form-group :label="$options.i18n.optionsLabel" class="gl-mt-4">
+ <gl-form-radio-group
+ v-model="isStraight"
+ :options="$options.compareOptions"
+ :name="$options.inputName"
+ required
+ />
+ </gl-form-group>
+ <div class="gl-display-flex gl-gap-3 gl-pb-4">
+ <gl-button
+ category="primary"
+ variant="confirm"
+ data-testid="compare-button"
+ @click="onSubmit"
+ >
+ {{ $options.i18n.compare }}
</gl-button>
<gl-button
v-if="projectMergeRequestPath"
:href="projectMergeRequestPath"
data-testid="projectMrButton"
>
- {{ s__('CompareRevisions|View open merge request') }}
+ {{ $options.i18n.viewMr }}
</gl-button>
<gl-button v-else-if="createMrPath" :href="createMrPath" data-testid="createMrButton">
- {{ s__('CompareRevisions|Create merge request') }}
+ {{ $options.i18n.openMr }}
</gl-button>
</div>
</form>
diff --git a/app/assets/javascripts/projects/compare/components/revision_card.vue b/app/assets/javascripts/projects/compare/components/revision_card.vue
index 162aca44f9d..212937c87c6 100644
--- a/app/assets/javascripts/projects/compare/components/revision_card.vue
+++ b/app/assets/javascripts/projects/compare/components/revision_card.vue
@@ -40,7 +40,7 @@ export default {
<template>
<div class="revision-card gl-flex-basis-half">
- <h2 class="gl-font-size-h2">
+ <h2 class="gl-font-base gl-mt-0">
{{ s__(`CompareRevisions|${revisionText}`) }}
</h2>
<div class="gl-sm-display-flex gl-align-items-center gl-gap-3">
diff --git a/app/assets/javascripts/projects/compare/constants.js b/app/assets/javascripts/projects/compare/constants.js
new file mode 100644
index 00000000000..2f07cf57521
--- /dev/null
+++ b/app/assets/javascripts/projects/compare/constants.js
@@ -0,0 +1,25 @@
+import { __, s__ } from '~/locale';
+import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility';
+
+export const COMPARE_OPTIONS_INPUT_NAME = 'straight';
+export const COMPARE_OPTIONS = [
+ { value: false, text: s__('CompareRevisions|Only incoming changes from source') },
+ { value: true, text: s__('CompareRevisions|Include changes to target since source was created') },
+];
+
+export const I18N = {
+ title: s__('CompareRevisions|Compare revisions'),
+ subtitle: s__(
+ 'CompareRevisions|Changes are shown as if the %{boldStart}source%{boldEnd} revision was being merged into the %{boldStart}target%{boldEnd} revision. %{linkStart}Learn more about comparing revisions.%{linkEnd}',
+ ),
+ source: __('Source'),
+ swap: s__('CompareRevisions|Swap'),
+ target: __('Target'),
+ swapRevisions: s__('CompareRevisions|Swap revisions'),
+ compare: s__('CompareRevisions|Compare'),
+ optionsLabel: s__('CompareRevisions|Show changes'),
+ viewMr: s__('CompareRevisions|View open merge request'),
+ openMr: s__('CompareRevisions|Create merge request'),
+};
+
+export const COMPARE_REVISIONS_DOCS_URL = `${DOCS_URL_IN_EE_DIR}/user/project/repository/branches/#compare-branches`;
diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue
index 6ca83b0b500..a841766a93c 100644
--- a/app/assets/javascripts/projects/new/components/app.vue
+++ b/app/assets/javascripts/projects/new/components/app.vue
@@ -1,8 +1,8 @@
<script>
-import createFromTemplateIllustration from '@gitlab/svgs/dist/illustrations/project-create-from-template-sm.svg?raw';
-import blankProjectIllustration from '@gitlab/svgs/dist/illustrations/project-create-new-sm.svg?raw';
-import importProjectIllustration from '@gitlab/svgs/dist/illustrations/project-import-sm.svg?raw';
-import ciCdProjectIllustration from '@gitlab/svgs/dist/illustrations/project-run-CICD-pipelines-sm.svg?raw';
+import PROJECT_CREATE_FROM_TEMPLATE_SVG_URL from '@gitlab/svgs/dist/illustrations/project-create-from-template-sm.svg?url';
+import PROJECT_CREATE_NEW_SVG_URL from '@gitlab/svgs/dist/illustrations/project-create-new-sm.svg?url';
+import PROJECT_IMPORT_SVG_URL from '@gitlab/svgs/dist/illustrations/project-import-sm.svg?url';
+import PROJECT_RUN_CICD_PIPELINES_SVG_URL from '@gitlab/svgs/dist/illustrations/project-run-CICD-pipelines-sm.svg?url';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { s__ } from '~/locale';
import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue';
@@ -19,7 +19,7 @@ const PANELS = [
description: s__(
'ProjectsNew|Create a blank project to store your files, plan your work, and collaborate on code, among other things.',
),
- illustration: blankProjectIllustration,
+ imageSrc: PROJECT_CREATE_NEW_SVG_URL,
},
{
key: 'template',
@@ -29,7 +29,7 @@ const PANELS = [
description: s__(
'ProjectsNew|Create a project pre-populated with the necessary files to get you started quickly.',
),
- illustration: createFromTemplateIllustration,
+ imageSrc: PROJECT_CREATE_FROM_TEMPLATE_SVG_URL,
},
{
key: 'import',
@@ -39,7 +39,7 @@ const PANELS = [
description: s__(
'ProjectsNew|Migrate your data from an external source like GitHub, Bitbucket, or another instance of GitLab.',
),
- illustration: importProjectIllustration,
+ imageSrc: PROJECT_IMPORT_SVG_URL,
},
{
key: 'ci',
@@ -47,7 +47,7 @@ const PANELS = [
selector: '#ci-cd-project-pane',
title: s__('ProjectsNew|Run CI/CD for external repository'),
description: s__('ProjectsNew|Connect your external repository to GitLab CI/CD.'),
- illustration: ciCdProjectIllustration,
+ imageSrc: PROJECT_RUN_CICD_PIPELINES_SVG_URL,
},
];
diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js
index d8675a851ea..75d72f719e5 100644
--- a/app/assets/javascripts/projects/settings/access_dropdown.js
+++ b/app/assets/javascripts/projects/settings/access_dropdown.js
@@ -3,6 +3,7 @@ import { escape, find, countBy } from 'lodash';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { createAlert } from '~/alert';
import { n__, s__, __, sprintf } from '~/locale';
+import { renderAvatar } from '~/helpers/avatar_helper';
import { getUsers, getGroups, getDeployKeys } from './api/access_dropdown_api';
import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants';
@@ -534,13 +535,22 @@ export default class AccessDropdown {
userRowHtml(user, isActive) {
const isActiveClass = isActive || '';
+ const avatarEl = renderAvatar(user, {
+ sizeClass: 's32',
+ });
return `
<li>
<a href="#" class="${isActiveClass}">
- <img src="${user.avatar_url}" class="avatar avatar-inline" width="30">
- <strong class="dropdown-menu-user-full-name">${escape(user.name)}</strong>
- <span class="dropdown-menu-user-username">${user.username}</span>
+ <div class="gl-avatar-labeled">
+ ${avatarEl}
+ <div>
+ <strong class="dropdown-menu-user-full-name">${escape(user.name)}</strong>
+ <span class="gl-avatar-labeled-sublabel dropdown-menu-user-username">@${
+ user.username
+ }</span>
+ </div>
+ </div>
</a>
</li>
`;
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
index 650b60cba4f..ae28694f5d2 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
@@ -7,8 +7,8 @@ import { __, sprintf } from '~/locale';
import ServiceDeskSetting from './service_desk_setting.vue';
export default {
- customEmailHelpPath: helpPagePath('/user/project/service_desk.html', {
- anchor: 'use-a-custom-email-address',
+ serviceDeskEmailHelpPath: helpPagePath('/user/project/service_desk.html', {
+ anchor: 'use-an-additional-service-desk-alias-email',
}),
components: {
GlAlert,
@@ -32,10 +32,10 @@ export default {
initialIncomingEmail: {
default: '',
},
- customEmail: {
+ serviceDeskEmail: {
default: '',
},
- customEmailEnabled: {
+ serviceDeskEmailEnabled: {
default: false,
},
selectedTemplate: {
@@ -65,7 +65,7 @@ export default {
alertVariant: 'danger',
alertMessage: '',
incomingEmail: this.initialIncomingEmail,
- updatedCustomEmail: this.customEmail,
+ updatedServiceDeskEmail: this.serviceDeskEmail,
};
},
methods: {
@@ -110,7 +110,7 @@ export default {
return axios
.put(this.endpoint, body)
.then(({ data }) => {
- this.updatedCustomEmail = data?.service_desk_address;
+ this.updatedServiceDeskEmail = data?.service_desk_address;
this.showAlert(__('Changes saved.'), 'success');
})
.catch((err) => {
@@ -155,7 +155,7 @@ export default {
"
>
<template #link="{ content }">
- <gl-link :href="$options.customEmailHelpPath" target="_blank">
+ <gl-link :href="$options.serviceDeskEmailHelpPath" target="_blank">
{{ content }}
</gl-link>
</template>
@@ -168,8 +168,8 @@ export default {
:is-enabled="isEnabled"
:is-issue-tracker-enabled="isIssueTrackerEnabled"
:incoming-email="incomingEmail"
- :custom-email="updatedCustomEmail"
- :custom-email-enabled="customEmailEnabled"
+ :service-desk-email="updatedServiceDeskEmail"
+ :service-desk-email-enabled="serviceDeskEmailEnabled"
:initial-selected-template="selectedTemplate"
:initial-selected-file-template-project-id="selectedFileTemplateProjectId"
:initial-outgoing-name="outgoingName"
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
index 38a2c12d137..5078cbbdf59 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
@@ -49,12 +49,12 @@ export default {
required: false,
default: '',
},
- customEmail: {
+ serviceDeskEmail: {
type: String,
required: false,
default: '',
},
- customEmailEnabled: {
+ serviceDeskEmailEnabled: {
type: Boolean,
required: false,
},
@@ -101,22 +101,22 @@ export default {
},
computed: {
hasProjectKeySupport() {
- return Boolean(this.customEmailEnabled);
+ return Boolean(this.serviceDeskEmailEnabled);
},
email() {
- return this.customEmail || this.incomingEmail;
+ return this.serviceDeskEmail || this.incomingEmail;
},
- hasCustomEmail() {
- return this.customEmail && this.customEmail !== this.incomingEmail;
+ hasServiceDeskEmail() {
+ return this.serviceDeskEmail && this.serviceDeskEmail !== this.incomingEmail;
},
emailSuffixHelpUrl() {
return helpPagePath('user/project/service_desk.html', {
- anchor: 'configure-a-custom-email-address-suffix',
+ anchor: 'configure-a-suffix-for-service-desk-alias-email',
});
},
- customEmailAddressHelpUrl() {
+ serviceDeskEmailAddressHelpUrl() {
return helpPagePath('user/project/service_desk.html', {
- anchor: 'use-a-custom-email-address',
+ anchor: 'use-an-additional-service-desk-alias-email',
});
},
issuesHelpPagePath() {
@@ -204,7 +204,7 @@ export default {
<clipboard-button :title="__('Copy')" :text="email" css-class="input-group-text" />
</template>
</gl-form-input-group>
- <template v-if="email && hasCustomEmail" #description>
+ <template v-if="email && hasServiceDeskEmail" #description>
<span class="gl-mt-2 gl-display-inline-block">
<gl-sprintf :message="__('Emails sent to %{email} are also supported.')">
<template #email>
@@ -260,7 +260,7 @@ export default {
>
<template #link="{ content }">
<gl-link
- :href="customEmailAddressHelpUrl"
+ :href="serviceDeskEmailAddressHelpUrl"
target="_blank"
class="gl-text-blue-600 font-size-inherit"
>{{ content }}
diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js
index 84229175c0b..0f4c747a7b6 100644
--- a/app/assets/javascripts/projects/settings_service_desk/index.js
+++ b/app/assets/javascripts/projects/settings_service_desk/index.js
@@ -10,8 +10,8 @@ export default () => {
}
const {
- customEmail,
- customEmailEnabled,
+ serviceDeskEmail,
+ serviceDeskEmailEnabled,
enabled,
issueTrackerEnabled,
endpoint,
@@ -27,8 +27,8 @@ export default () => {
return new Vue({
el,
provide: {
- customEmail,
- customEmailEnabled: parseBoolean(customEmailEnabled),
+ serviceDeskEmail,
+ serviceDeskEmailEnabled: parseBoolean(serviceDeskEmailEnabled),
endpoint,
initialIncomingEmail: incomingEmail,
initialIsEnabled: parseBoolean(enabled),
diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status.vue
index 5b620aa2300..5b620aa2300 100644
--- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
+++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status.vue
diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue
index f672acda062..1044d25c1a3 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -184,21 +184,14 @@ export default {
<template>
<div id="related-issues" class="related-issues-block">
<gl-card
- class="gl-overflow-hidden gl-mt-5 gl-mb-0"
- header-class="gl-p-0 gl-border-0"
- body-class="gl-p-0 gl-bg-gray-10"
+ class="gl-new-card gl-overflow-hidden"
+ header-class="gl-new-card-header"
+ body-class="gl-new-card-body"
+ :aria-expanded="isOpen.toString()"
>
<template #header>
- <div
- :class="{
- 'gl-border-b-1': isOpen,
- 'gl-border-b-0': !isOpen,
- }"
- class="gl-display-flex gl-justify-content-space-between gl-pl-5 gl-pr-4 gl-py-4 gl-bg-white gl-border-b-solid gl-border-b-gray-100"
- >
- <h3
- class="card-title h5 gl-relative gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1 gl-line-height-24"
- >
+ <div class="gl-new-card-title-wrapper">
+ <h3 class="gl-new-card-title" data-testid="card-title">
<gl-link
id="user-content-related-issues"
class="anchor position-absolute gl-text-decoration-none"
@@ -206,48 +199,44 @@ export default {
aria-hidden="true"
/>
<slot name="header-text">{{ headerText }}</slot>
-
- <div
- class="js-related-issues-header-issue-count gl-display-inline-flex gl-mx-3 gl-text-gray-500"
- >
- <span class="gl-display-inline-flex gl-align-items-center">
- <gl-icon :name="issuableTypeIcon" class="gl-mr-2 gl-text-gray-500" />
- {{ badgeLabel }}
- </span>
- </div>
</h3>
- <slot name="header-actions"></slot>
+ <div class="gl-new-card-count js-related-issues-header-issue-count">
+ <gl-icon :name="issuableTypeIcon" class="gl-mr-2" />
+ {{ badgeLabel }}
+ </div>
+ </div>
+ <slot name="header-actions"></slot>
+ <gl-button
+ v-if="canAdmin"
+ size="small"
+ data-testid="related-issues-plus-button"
+ :aria-label="addIssuableButtonText"
+ class="gl-ml-3"
+ @click="addButtonClick"
+ >
+ <slot name="add-button-text">{{ __('Add') }}</slot>
+ </gl-button>
+ <div class="gl-new-card-toggle">
<gl-button
- v-if="canAdmin"
+ category="tertiary"
size="small"
- data-testid="related-issues-plus-button"
- :aria-label="addIssuableButtonText"
- class="gl-ml-3"
- @click="addButtonClick"
- >
- <slot name="add-button-text">{{ __('Add') }}</slot>
- </gl-button>
- <div class="gl-pl-3 gl-ml-3 gl-border-l-1 gl-border-l-solid gl-border-l-gray-100">
- <gl-button
- category="tertiary"
- size="small"
- :icon="toggleIcon"
- :aria-label="toggleLabel"
- data-testid="toggle-links"
- @click="handleToggle"
- />
- </div>
+ :icon="toggleIcon"
+ :aria-label="toggleLabel"
+ data-testid="toggle-links"
+ @click="handleToggle"
+ />
</div>
</template>
<div
v-if="isOpen"
- class="linked-issues-card-body gl-py-3 gl-px-4 gl-bg-gray-10"
+ class="linked-issues-card-body gl-new-card-content"
data-testid="related-issues-body"
>
<div
v-if="isFormVisible"
- class="js-add-related-issues-form-area card-body bg-white gl-mt-2 gl-border-1 gl-border-solid gl-border-gray-100 gl-rounded-base"
+ class="js-add-related-issues-form-area gl-new-card-add-form"
:class="{ 'gl-mb-5': shouldShowTokenBody, 'gl-show-field-errors': hasError }"
+ data-testid="add-item-form"
>
<add-issuable-form
:show-categorized-issues="showCategorizedIssues"
@@ -289,8 +278,8 @@ export default {
@saveReorder="$emit('saveReorder', $event)"
/>
</template>
- <div v-if="!shouldShowTokenBody && !isFormVisible" data-testid="related-items-empty">
- <p class="gl-p-2 gl-mb-0 gl-text-gray-500">
+ <div v-if="!shouldShowTokenBody && !isFormVisible">
+ <p class="gl-new-card-empty">
{{ emptyStateMessage }}
<gl-link
v-if="hasHelpPath"
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index ff92cdd42c6..516162b57b5 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -215,14 +215,13 @@ export default {
</gl-form-group>
<gl-form-group data-testid="release-notes">
<label for="release-notes">{{ __('Release notes') }}</label>
- <div class="bordered-box pr-3 pl-3">
+ <div class="common-note-form">
<markdown-field
:can-attach-file="true"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:add-spacing-classes="false"
:textarea-value="formattedReleaseNotes"
- class="gl-mt-3 gl-mb-3"
>
<template #textarea>
<textarea
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index e056a822c8b..969036f84b7 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -7,14 +7,16 @@ import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constant
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { isLoggedIn, handleLocationHash } from '~/lib/utils/common_utils';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import { redirectTo, getLocationHash } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
+import WebIdeLink from 'ee_else_ce/vue_shared/components/web_ide_link.vue';
import CodeIntelligence from '~/code_navigation/components/app.vue';
import LineHighlighter from '~/blob/line_highlighter';
import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql';
import { addBlameLink } from '~/blob/blob_blame_link';
+import highlightMixin from '~/repository/mixins/highlight_mixin';
import projectInfoQuery from '../queries/project_info.query.graphql';
import getRefMixin from '../mixins/get_ref';
import userInfoQuery from '../queries/user_info.query.graphql';
@@ -36,7 +38,7 @@ export default {
CodeIntelligence,
AiGenie: () => import('ee_component/ai/components/ai_genie.vue'),
},
- mixins: [getRefMixin, glFeatureFlagMixin()],
+ mixins: [getRefMixin, glFeatureFlagMixin(), highlightMixin],
inject: {
originalBranch: {
default: '',
@@ -81,7 +83,10 @@ export default {
shouldFetchRawText: Boolean(this.glFeatures.highlightJs),
};
},
- result() {
+ result({ data }) {
+ const blob = data.project?.repository?.blobs?.nodes[0] || {};
+ this.initHighlightWorker(blob);
+
const urlHash = getLocationHash();
const plain = this.$route?.query?.plain;
@@ -170,7 +175,14 @@ export default {
},
blobViewer() {
const { fileType } = this.viewer;
- return this.shouldLoadLegacyViewer ? null : loadViewer(fileType, this.isUsingLfs);
+ return this.shouldLoadLegacyViewer
+ ? null
+ : loadViewer(
+ fileType,
+ this.isUsingLfs,
+ this.glFeatures.highlightJsWorker,
+ this.blobInfo.language,
+ );
},
shouldLoadLegacyViewer() {
const isTextFile = this.viewer.fileType === TEXT_FILE_TYPE && !this.glFeatures.highlightJs;
@@ -215,6 +227,9 @@ export default {
isUsingLfs() {
return this.blobInfo.storedExternally && this.blobInfo.externalStorage === LFS_STORAGE;
},
+ projectIdAsNumber() {
+ return getIdFromGraphQLId(this.project?.id);
+ },
},
watch: {
// Watch the URL 'plain' query value to know if the viewer needs changing.
@@ -345,6 +360,8 @@ export default {
:gitpod-url="blobInfo.gitpodBlobUrl"
:show-gitpod-button="gitpodEnabled"
:gitpod-enabled="currentUser && currentUser.gitpodEnabled"
+ :project-path="projectPath"
+ :project-id="projectIdAsNumber"
:user-preferences-gitpod-path="currentUser && currentUser.preferencesGitpodPath"
:user-profile-enable-gitpod-path="currentUser && currentUser.profileEnableGitpodPath"
is-blob
@@ -386,7 +403,14 @@ export default {
:loading="isLoadingLegacyViewer"
:data-loading="isRenderingLegacyTextViewer"
/>
- <component :is="blobViewer" v-else :blob="blobInfo" class="blob-viewer" @error="onError" />
+ <component
+ :is="blobViewer"
+ v-else
+ :blob="blobInfo"
+ :chunks="chunks"
+ class="blob-viewer"
+ @error="onError"
+ />
<code-intelligence
v-if="blobViewer || legacyViewerLoaded"
:code-navigation-path="blobInfo.codeNavigationPath"
diff --git a/app/assets/javascripts/repository/components/blob_viewers/geo_json/constants.js b/app/assets/javascripts/repository/components/blob_viewers/geo_json/constants.js
index fd4d111b4b0..2c95e63720e 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/geo_json/constants.js
+++ b/app/assets/javascripts/repository/components/blob_viewers/geo_json/constants.js
@@ -7,7 +7,7 @@ export const RENDER_ERROR_MSG = __(
'The map can not be displayed because there was an error loading the GeoJSON file.',
);
-export const OPEN_STREET_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
+export const OPEN_STREET_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
export const ICON_CONFIG = { iconUrl, iconRetinaUrl, shadowUrl };
export const MAP_ATTRIBUTION = __('Map data from');
export const OPEN_STREET_COPYRIGHT_LINK =
diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js
index b749702972f..368f42e0064 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/index.js
+++ b/app/assets/javascripts/repository/components/blob_viewers/index.js
@@ -15,9 +15,15 @@ const viewers = {
geo_json: () => import('./geo_json/geo_json_viewer.vue'),
};
-export const loadViewer = (type, isUsingLfs) => {
+export const loadViewer = (type, isUsingLfs, hljsWorkerEnabled, language) => {
let viewer = viewers[type];
+ if (hljsWorkerEnabled && language === 'json') {
+ // The New Source Viewer currently only supports JSON files.
+ // More language support will be added in: https://gitlab.com/gitlab-org/gitlab/-/issues/415753
+ viewer = () => import('~/vue_shared/components/source_viewer/source_viewer_new.vue');
+ }
+
if (!viewer && isUsingLfs) {
viewer = viewers.lfs;
}
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index 46dee9db69a..d498be0b2bb 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -1,14 +1,8 @@
<script>
-import {
- GlDropdown,
- GlDropdownDivider,
- GlDropdownSectionHeader,
- GlDropdownItem,
- GlIcon,
- GlModalDirective,
-} from '@gitlab/ui';
+import { GlDisclosureDropdown, GlModalDirective } from '@gitlab/ui';
import permissionsQuery from 'shared_queries/repository/permissions.query.graphql';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
+import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { __ } from '~/locale';
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
@@ -16,21 +10,12 @@ import projectShortPathQuery from '../queries/project_short_path.query.graphql';
import UploadBlobModal from './upload_blob_modal.vue';
import NewDirectoryModal from './new_directory_modal.vue';
-const ROW_TYPES = {
- header: 'header',
- divider: 'divider',
-};
-
const UPLOAD_BLOB_MODAL_ID = 'modal-upload-blob';
const NEW_DIRECTORY_MODAL_ID = 'modal-new-directory';
export default {
components: {
- GlDropdown,
- GlDropdownDivider,
- GlDropdownSectionHeader,
- GlDropdownItem,
- GlIcon,
+ GlDisclosureDropdown,
UploadBlobModal,
NewDirectoryModal,
},
@@ -171,103 +156,99 @@ export default {
canCreateMrFromFork() {
return this.userPermissions?.forkProject && this.userPermissions?.createMergeRequestIn;
},
+ hasPushCodePermission() {
+ return this.userPermissions?.pushCode;
+ },
showUploadModal() {
return this.canEditTree && !this.$apollo.queries.userPermissions.loading;
},
showNewDirectoryModal() {
return this.canEditTree && !this.$apollo.queries.userPermissions.loading;
},
- dropdownItems() {
- const items = [];
-
+ dropdownDirectoryItems() {
if (this.canEditTree) {
- items.push(
+ return [
{
- type: ROW_TYPES.header,
- text: __('This directory'),
- },
- {
- attrs: {
- href: `${this.newBlobPath}/${
- this.currentPath ? encodeURIComponent(this.currentPath) : ''
- }`,
+ text: __('New file'),
+ href: joinPaths(
+ this.newBlobPath,
+ this.currentPath ? encodeURIComponent(this.currentPath) : '',
+ ),
+ extraAttrs: {
'data-qa-selector': 'new_file_menu_item',
},
- text: __('New file'),
},
{
- attrs: {
- href: '#modal-upload-blob',
- },
text: __('Upload file'),
- modalId: UPLOAD_BLOB_MODAL_ID,
- },
- );
-
- items.push({
- attrs: {
- href: '#modal-create-new-dir',
- },
- text: __('New directory'),
- modalId: NEW_DIRECTORY_MODAL_ID,
- });
- } else if (this.canCreateMrFromFork) {
- items.push(
- {
- attrs: {
- href: this.forkNewBlobPath,
- 'data-method': 'post',
- },
- text: __('New file'),
+ action: () => this.$root.$emit(BV_SHOW_MODAL, UPLOAD_BLOB_MODAL_ID),
},
{
- attrs: {
- href: this.forkUploadBlobPath,
- 'data-method': 'post',
- },
- text: __('Upload file'),
- },
- {
- attrs: {
- href: this.forkNewDirectoryPath,
- 'data-method': 'post',
- },
text: __('New directory'),
+ action: () => this.$root.$emit(BV_SHOW_MODAL, NEW_DIRECTORY_MODAL_ID),
},
- );
+ ];
}
- if (this.userPermissions?.pushCode) {
- items.push(
+ if (this.canCreateMrFromFork) {
+ return [
{
- type: ROW_TYPES.divider,
- },
- {
- type: ROW_TYPES.header,
- text: __('This repository'),
+ text: __('New file'),
+ href: this.forkNewBlobPath,
+ extraAttrs: {
+ 'data-method': 'post',
+ },
},
{
- attrs: {
- href: this.newBranchPath,
+ text: __('Upload file'),
+ href: this.forkUploadBlobPath,
+ extraAttrs: {
+ 'data-method': 'post',
},
- text: __('New branch'),
},
{
- attrs: {
- href: this.newTagPath,
+ text: __('New directory'),
+ href: this.forkNewDirectoryPath,
+ extraAttrs: {
+ 'data-method': 'post',
},
- text: __('New tag'),
},
- );
+ ];
}
- return items;
+ return [];
+ },
+ dropdownRepositoryItems() {
+ if (!this.hasPushCodePermission) return [];
+ return [
+ {
+ text: __('New branch'),
+ href: this.newBranchPath,
+ },
+ {
+ text: __('New tag'),
+ href: this.newTagPath,
+ },
+ ];
+ },
+ dropdownItems() {
+ if (this.isBlobPath) return [];
+ if (!this.canCollaborate && !this.canCreateMrFromFork) return [];
+ return [
+ this.dropdownDirectoryItems?.length && {
+ name: __('This directory'),
+ items: this.dropdownDirectoryItems,
+ },
+ this.dropdownRepositoryItems?.length && {
+ name: __('This repository'),
+ items: this.dropdownRepositoryItems,
+ },
+ ].filter(Boolean);
},
isBlobPath() {
return this.$route.name === 'blobPath' || this.$route.name === 'blobPathDecoded';
},
renderAddToTreeDropdown() {
- return !this.isBlobPath && (this.canCollaborate || this.canCreateMrFromFork);
+ return this.dropdownItems.length;
},
newDirectoryPath() {
return joinPaths(this.newDirPath, this.currentPath);
@@ -277,16 +258,6 @@ export default {
isLast(i) {
return i === this.pathLinks.length - 1;
},
- getComponent(type) {
- switch (type) {
- case ROW_TYPES.divider:
- return 'gl-dropdown-divider';
- case ROW_TYPES.header:
- return 'gl-dropdown-section-header';
- default:
- return 'gl-dropdown-item';
- }
- },
},
};
</script>
@@ -300,27 +271,15 @@ export default {
</router-link>
</li>
<li v-if="renderAddToTreeDropdown" class="breadcrumb-item">
- <gl-dropdown
+ <gl-disclosure-dropdown
+ :toggle-text="__('Add to tree')"
toggle-class="add-to-tree gl-ml-2"
data-testid="add-to-tree"
data-qa-selector="add_to_tree_dropdown"
- >
- <template #button-content>
- <span class="sr-only">{{ __('Add to tree') }}</span>
- <gl-icon name="plus" :size="16" class="float-left" />
- <gl-icon name="chevron-down" :size="16" class="float-left" />
- </template>
- <template v-for="(item, i) in dropdownItems">
- <component
- :is="getComponent(item.type)"
- :key="i"
- v-bind="item.attrs"
- v-gl-modal="item.modalId || null"
- >
- {{ item.text }}
- </component>
- </template>
- </gl-dropdown>
+ text-sr-only
+ icon="plus"
+ :items="dropdownItems"
+ />
</li>
</ol>
<upload-blob-modal
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 82dd1fda2a0..bdc9ed210ed 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -138,7 +138,7 @@ export default {
:size="32"
/>
<div
- class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-flex-grow-1 gl-min-w-0"
+ class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-grow-1 gl-min-w-0"
>
<div class="commit-content" data-qa-selector="commit_content">
<gl-link
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index befd731a61b..c1e0104c6ac 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -8,6 +8,7 @@ import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import PerformancePlugin from '~/performance/vue_performance_plugin';
import createStore from '~/code_navigation/store';
import RefSelector from '~/ref/components/ref_selector.vue';
+import HighlightWorker from '~/vue_shared/components/source_viewer/workers/highlight_worker?worker';
import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
@@ -290,7 +291,12 @@ export default function setupVueRepositoryList() {
store: createStore(),
router,
apolloProvider,
- provide: { resourceId, userId, explainCodeAvailable: parseBoolean(explainCodeAvailable) },
+ provide: {
+ resourceId,
+ userId,
+ explainCodeAvailable: parseBoolean(explainCodeAvailable),
+ highlightWorker: gon.features.highlightJsWorker ? new HighlightWorker() : null,
+ },
render(h) {
return h(App);
},
diff --git a/app/assets/javascripts/repository/mixins/highlight_mixin.js b/app/assets/javascripts/repository/mixins/highlight_mixin.js
index 95d0c55bb04..822a8b4ee38 100644
--- a/app/assets/javascripts/repository/mixins/highlight_mixin.js
+++ b/app/assets/javascripts/repository/mixins/highlight_mixin.js
@@ -1,4 +1,3 @@
-import { nextTick } from 'vue';
import {
LEGACY_FALLBACKS,
EVENT_ACTION,
@@ -38,8 +37,8 @@ export default {
this.trackEvent(EVENT_LABEL_FALLBACK, language);
this?.onError();
},
- initHighlightWorker({ rawTextBlob, language, simpleViewer }) {
- if (simpleViewer?.fileType !== TEXT_FILE_TYPE) return;
+ initHighlightWorker({ rawTextBlob, language, simpleViewer, fileType }) {
+ if (simpleViewer?.fileType !== TEXT_FILE_TYPE || !this.glFeatures.highlightJsWorker) return;
if (this.isUnsupportedLanguage(language)) {
this.handleUnsupportedLanguage(language);
@@ -72,14 +71,14 @@ export default {
this.instructWorker(firstSeventyLines, language);
// Instruct the worker to start highlighting all lines in the background.
- this.instructWorker(rawTextBlob, language);
+ this.instructWorker(rawTextBlob, language, fileType);
},
handleWorkerMessage({ data }) {
this.chunks = data;
this.highlightHash(); // highlight the line if a line number hash is present in the URL
},
- instructWorker(content, language) {
- this.highlightWorker.postMessage({ content, language });
+ instructWorker(content, language, fileType) {
+ this.highlightWorker.postMessage({ content, language, fileType });
},
async highlightHash() {
const { hash } = this.$route;
@@ -97,7 +96,7 @@ export default {
}
// Line numbers in the DOM needs to update first based on changes made to `chunks`.
- await nextTick();
+ await this.$nextTick();
const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' });
lineHighlighter.highlightHash(hash);
diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue
index 74855482b5d..eb3556ac2cf 100644
--- a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue
+++ b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue
@@ -14,17 +14,11 @@ import { mapActions, mapState, mapGetters } from 'vuex';
import { uniq } from 'lodash';
import { rgbFromHex } from '@gitlab/ui/dist/utils/utils';
import { slugify } from '~/lib/utils/text_utility';
-import { s__, sprintf } from '~/locale';
+import { sprintf } from '~/locale';
import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
-import {
- SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN,
- SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN,
- SEARCH_DESCRIBED_BY_DEFAULT,
- SEARCH_DESCRIBED_BY_UPDATED,
- SEARCH_RESULTS_LOADING,
-} from '~/vue_shared/global_search/constants';
+import { I18N } from '~/vue_shared/global_search/constants';
import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants';
import LabelDropdownItems from './label_dropdown_items.vue';
@@ -60,16 +54,7 @@ export default {
isFocused: false,
};
},
- i18n: {
- SEARCH_LABELS: s__('GlobalSearch|Search labels'),
- DROPDOWN_HEADER: s__('GlobalSearch|Label(s)'),
- AGGREGATIONS_ERROR_MESSAGE: s__('GlobalSearch|Fetching aggregations error.'),
- SEARCH_DESCRIBED_BY_DEFAULT,
- SEARCH_RESULTS_LOADING,
- SEARCH_DESCRIBED_BY_UPDATED,
- SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN,
- SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN,
- },
+ i18n: I18N,
computed: {
...mapState(['useSidebarNavigation', 'searchLabelString', 'query', 'aggregations']),
...mapGetters([
@@ -260,7 +245,7 @@ export default {
:default-index="defaultIndex"
:enable-cycle="true"
/>
- <div v-if="!aggregations.error">
+ <div v-if="!aggregations.error && filteredLabels.length > 0">
<gl-dropdown-section-header v-if="hasSelectedLabels || hasUnselectedLabels">{{
$options.i18n.DROPDOWN_HEADER
}}</gl-dropdown-section-header>
@@ -280,7 +265,13 @@ export default {
</gl-form-checkbox-group>
</gl-dropdown-form>
</div>
- <gl-alert v-else :dismissible="false" variant="danger">
+ <span
+ v-if="!aggregations.error && filteredLabels.length === 0"
+ class="gl-px-3"
+ data-testid="no-labels-found-message"
+ >{{ $options.i18n.NO_LABELS_FOUND }}</span
+ >
+ <gl-alert v-if="aggregations.error" :dismissible="false" variant="danger">
{{ $options.i18n.AGGREGATIONS_ERROR_MESSAGE }}
</gl-alert>
<gl-loading-icon v-if="aggregations.fetching" size="lg" class="my-4" />
diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue b/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue
index 7a9e6a2e4fc..0b468a60cf0 100644
--- a/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue
+++ b/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue
@@ -26,15 +26,15 @@ export default {
class="gl-px-5 gl-py-3 label-filter-menu-item"
>
<gl-form-checkbox
- class="label-with-color-checkbox gl-display-inline-flex gl-h-5 gl-min-h-5"
+ class="label-with-color-checkbox gl-display-inline-flex gl-min-h-5"
:value="label.key"
>
<span
data-testid="label-color-indicator"
- class="gl-rounded-base gl-w-5 gl-h-5 gl-display-inline-block gl-vertical-align-bottom gl-mr-3"
+ class="gl-rounded-base gl-min-w-5 gl-h-5 gl-display-inline-block gl-vertical-align-bottom gl-mr-3"
:style="{ 'background-color': label.color }"
></span>
- <span class="gl-reset-text-align gl-m-0 gl-p-0 label-title">{{
+ <span class="gl-reset-text-align gl-m-0 gl-p-0 label-title gl-word-break-all">{{
label.title
}}</span></gl-form-checkbox
>
diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js
index 91c16616f02..bb112c122ae 100644
--- a/app/assets/javascripts/search/store/constants.js
+++ b/app/assets/javascripts/search/store/constants.js
@@ -23,11 +23,13 @@ export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: '
export const ICON_MAP = {
blobs: 'code',
issues: 'issues',
+ epics: 'epic',
merge_requests: 'merge-request',
commits: 'commit',
notes: 'comments',
- milestones: 'tag',
+ milestones: 'clock',
users: 'users',
projects: 'project',
- wiki_blobs: 'overview',
+ wiki_blobs: 'book',
+ snippet_titles: 'snippet',
};
diff --git a/app/assets/javascripts/search/topbar/components/group_filter.vue b/app/assets/javascripts/search/topbar/components/group_filter.vue
index e5edb21792a..4798f1127eb 100644
--- a/app/assets/javascripts/search/topbar/components/group_filter.vue
+++ b/app/assets/javascripts/search/topbar/components/group_filter.vue
@@ -19,7 +19,7 @@ export default {
},
computed: {
...mapState(['query', 'groups', 'fetchingGroups']),
- ...mapGetters(['frequentGroups']),
+ ...mapGetters(['frequentGroups', 'currentScope']),
selectedGroup() {
return isEmpty(this.initialData) ? ANY_OPTION : this.initialData;
},
@@ -43,6 +43,7 @@ export default {
[GROUP_DATA.queryParam]: group.id,
[PROJECT_DATA.queryParam]: null,
nav_source: null,
+ scope: this.currentScope,
}),
);
},
diff --git a/app/assets/javascripts/search/topbar/components/project_filter.vue b/app/assets/javascripts/search/topbar/components/project_filter.vue
index 85cf2ddbbff..1cce3e3db8b 100644
--- a/app/assets/javascripts/search/topbar/components/project_filter.vue
+++ b/app/assets/javascripts/search/topbar/components/project_filter.vue
@@ -18,7 +18,7 @@ export default {
},
computed: {
...mapState(['query', 'projects', 'fetchingProjects']),
- ...mapGetters(['frequentProjects']),
+ ...mapGetters(['frequentProjects', 'currentScope']),
selectedProject() {
return this.initialData ? this.initialData : ANY_OPTION;
},
@@ -42,6 +42,7 @@ export default {
...(project.namespace?.id && { [GROUP_DATA.queryParam]: project.namespace.id }),
[PROJECT_DATA.queryParam]: project.id,
nav_source: null,
+ scope: this.currentScope,
};
visitUrl(setUrlParams(queryParams));
diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
index 578d7c8a18c..f5f88e12163 100644
--- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue
+++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
@@ -247,7 +247,7 @@ export default {
:label="__('Training mode')"
label-position="hidden"
:disabled="!securityTrainingEnabled"
- data-qa-selector="security_training_toggle"
+ data-testid="security-training-toggle"
:data-qa-training-provider="provider.name"
@change="toggleProvider(provider)"
/>
diff --git a/app/assets/javascripts/service_desk/components/info_banner.vue b/app/assets/javascripts/service_desk/components/info_banner.vue
new file mode 100644
index 00000000000..8aaced839a5
--- /dev/null
+++ b/app/assets/javascripts/service_desk/components/info_banner.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlLink, GlButton } from '@gitlab/ui';
+import {
+ infoBannerTitle,
+ infoBannerAdminNote,
+ infoBannerUserNote,
+ enableServiceDesk,
+ learnMore,
+} from '../constants';
+
+export default {
+ name: 'InfoBanner',
+ components: {
+ GlLink,
+ GlButton,
+ },
+ inject: [
+ 'serviceDeskCalloutSvgPath',
+ 'serviceDeskEmailAddress',
+ 'canAdminIssues',
+ 'canEditProjectSettings',
+ 'serviceDeskSettingsPath',
+ 'isServiceDeskEnabled',
+ 'serviceDeskHelpPath',
+ ],
+ i18n: { infoBannerTitle, infoBannerAdminNote, infoBannerUserNote, enableServiceDesk, learnMore },
+ computed: {
+ canSeeEmailAddress() {
+ return this.canAdminIssues && this.isServiceDeskEnabled;
+ },
+ canEnableServiceDesk() {
+ return this.canEditProjectSettings && !this.isServiceDeskEnabled;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-border-b gl-pb-3 gl-display-flex gl-align-items-flex-start">
+ <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings -->
+ <img
+ :src="serviceDeskCalloutSvgPath"
+ alt=""
+ class="gl-display-none gl-sm-display-block gl-p-5"
+ />
+ <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings -->
+ <div class="gl-mt-3 gl-ml-3">
+ <h5>{{ $options.i18n.infoBannerTitle }}</h5>
+ <p v-if="canSeeEmailAddress">
+ {{ $options.i18n.infoBannerAdminNote }} <code>{{ serviceDeskEmailAddress }}</code>
+ </p>
+ <p>
+ {{ $options.i18n.infoBannerUserNote }}
+ <gl-link :href="serviceDeskHelpPath" target="_blank">{{ $options.i18n.learnMore }}</gl-link
+ >.
+ </p>
+ <p v-if="canEnableServiceDesk" class="gl-mt-3">
+ <gl-button :href="serviceDeskSettingsPath" variant="confirm">{{
+ $options.i18n.enableServiceDesk
+ }}</gl-button>
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/service_desk/components/service_desk_list_app.vue b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue
new file mode 100644
index 00000000000..e8b05642e7d
--- /dev/null
+++ b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue
@@ -0,0 +1,151 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { fetchPolicies } from '~/lib/graphql';
+import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
+import { issuableListTabs } from '~/vue_shared/issuable/list/constants';
+import { STATUS_OPEN, STATUS_CLOSED, STATUS_ALL } from '~/issues/constants';
+import getServiceDeskIssuesQuery from '../queries/get_service_desk_issues.query.graphql';
+import getServiceDeskIssuesCounts from '../queries/get_service_desk_issues_counts.query.graphql';
+import {
+ errorFetchingCounts,
+ errorFetchingIssues,
+ noSearchNoFilterTitle,
+ searchPlaceholder,
+ SERVICE_DESK_BOT_USERNAME,
+} from '../constants';
+import InfoBanner from './info_banner.vue';
+
+export default {
+ i18n: {
+ errorFetchingCounts,
+ errorFetchingIssues,
+ noSearchNoFilterTitle,
+ searchPlaceholder,
+ },
+ issuableListTabs,
+ components: {
+ GlEmptyState,
+ IssuableList,
+ InfoBanner,
+ },
+ inject: [
+ 'emptyStateSvgPath',
+ 'isProject',
+ 'isSignedIn',
+ 'fullPath',
+ 'isServiceDeskSupported',
+ 'hasAnyIssues',
+ ],
+ data() {
+ return {
+ serviceDeskIssues: [],
+ serviceDeskIssuesCounts: {},
+ searchTokens: [],
+ sortOptions: [],
+ state: STATUS_OPEN,
+ issuesError: null,
+ };
+ },
+ apollo: {
+ serviceDeskIssues: {
+ query: getServiceDeskIssuesQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return data.project.issues.nodes ?? [];
+ },
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ // We need this for handling loading state when using frontend cache
+ // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106004#note_1217325202 for details
+ notifyOnNetworkStatusChange: true,
+ result({ data }) {
+ if (!data) {
+ return;
+ }
+ this.pageInfo = data?.project.issues.pageInfo ?? {};
+ },
+ error(error) {
+ this.issuesError = this.$options.i18n.errorFetchingIssues;
+ Sentry.captureException(error);
+ },
+ skip() {
+ return !this.hasAnyIssues;
+ },
+ },
+ serviceDeskIssuesCounts: {
+ query: getServiceDeskIssuesCounts,
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return data?.project ?? {};
+ },
+ error(error) {
+ this.issuesError = this.$options.i18n.errorFetchingCounts;
+ Sentry.captureException(error);
+ },
+ context: {
+ isSingleRequest: true,
+ },
+ },
+ },
+ computed: {
+ queryVariables() {
+ return {
+ fullPath: this.fullPath,
+ isProject: this.isProject,
+ isSignedIn: this.isSignedIn,
+ authorUsername: SERVICE_DESK_BOT_USERNAME,
+ state: this.state,
+ };
+ },
+ tabCounts() {
+ const { openedIssues, closedIssues, allIssues } = this.serviceDeskIssuesCounts;
+ return {
+ [STATUS_OPEN]: openedIssues?.count,
+ [STATUS_CLOSED]: closedIssues?.count,
+ [STATUS_ALL]: allIssues?.count,
+ };
+ },
+ isInfoBannerVisible() {
+ return this.isServiceDeskSupported && this.hasAnyIssues;
+ },
+ },
+ methods: {
+ handleClickTab(state) {
+ if (this.state === state) {
+ return;
+ }
+ this.state = state;
+ },
+ },
+};
+</script>
+
+<template>
+ <section>
+ <info-banner v-if="isInfoBannerVisible" />
+ <issuable-list
+ namespace="service-desk"
+ recent-searches-storage-key="issues"
+ :error="issuesError"
+ :search-input-placeholder="$options.i18n.searchPlaceholder"
+ :search-tokens="searchTokens"
+ :sort-options="sortOptions"
+ :issuables="serviceDeskIssues"
+ :tabs="$options.issuableListTabs"
+ :tab-counts="tabCounts"
+ :current-tab="state"
+ @click-tab="handleClickTab"
+ >
+ <template #empty-state>
+ <gl-empty-state
+ :svg-path="emptyStateSvgPath"
+ :title="$options.i18n.noSearchNoFilterTitle"
+ />
+ </template>
+ </issuable-list>
+ </section>
+</template>
diff --git a/app/assets/javascripts/service_desk/constants.js b/app/assets/javascripts/service_desk/constants.js
new file mode 100644
index 00000000000..685ad738792
--- /dev/null
+++ b/app/assets/javascripts/service_desk/constants.js
@@ -0,0 +1,17 @@
+import { __, s__ } from '~/locale';
+
+export const SERVICE_DESK_BOT_USERNAME = 'support-bot';
+
+export const errorFetchingCounts = __('An error occurred while getting issue counts');
+export const errorFetchingIssues = __('An error occurred while loading issues');
+export const noSearchNoFilterTitle = __('Please select at least one filter to see results');
+export const searchPlaceholder = __('Search or filter results...');
+export const infoBannerTitle = s__(
+ 'ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab',
+);
+export const infoBannerAdminNote = s__('ServiceDesk|Your users can send emails to this address:');
+export const infoBannerUserNote = s__(
+ 'ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation.',
+);
+export const enableServiceDesk = s__('ServiceDesk|Enable Service Desk');
+export const learnMore = __('Learn more');
diff --git a/app/assets/javascripts/service_desk/graphql.js b/app/assets/javascripts/service_desk/graphql.js
new file mode 100644
index 00000000000..e01973f1e8a
--- /dev/null
+++ b/app/assets/javascripts/service_desk/graphql.js
@@ -0,0 +1,24 @@
+import createDefaultClient, { createApolloClientWithCaching } from '~/lib/graphql';
+
+let client;
+
+const typePolicies = {
+ Project: {
+ fields: {
+ issues: {
+ merge: true,
+ },
+ },
+ },
+};
+
+export async function gqlClient() {
+ if (client) return client;
+ client = gon.features?.frontendCaching
+ ? await createApolloClientWithCaching(
+ {},
+ { localCacheKey: 'service_desk_list', cacheConfig: { typePolicies } },
+ )
+ : createDefaultClient({}, { cacheConfig: { typePolicies } });
+ return client;
+}
diff --git a/app/assets/javascripts/service_desk/index.js b/app/assets/javascripts/service_desk/index.js
new file mode 100644
index 00000000000..a9172f96540
--- /dev/null
+++ b/app/assets/javascripts/service_desk/index.js
@@ -0,0 +1,55 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { gqlClient } from './graphql';
+import ServiceDeskListApp from './components/service_desk_list_app.vue';
+
+export async function mountServiceDeskListApp() {
+ const el = document.querySelector('.js-service-desk-list');
+
+ if (!el) {
+ return null;
+ }
+
+ const {
+ projectDataEmptyStateSvgPath,
+ projectDataFullPath,
+ projectDataIsProject,
+ projectDataIsSignedIn,
+ projectDataHasAnyIssues,
+ serviceDeskEmailAddress,
+ canAdminIssues,
+ canEditProjectSettings,
+ serviceDeskCalloutSvgPath,
+ serviceDeskSettingsPath,
+ serviceDeskHelpPath,
+ isServiceDeskSupported,
+ isServiceDeskEnabled,
+ } = el.dataset;
+
+ Vue.use(VueApollo);
+
+ return new Vue({
+ el,
+ name: 'ServiceDeskListRoot',
+ apolloProvider: new VueApollo({
+ defaultClient: await gqlClient(),
+ }),
+ provide: {
+ emptyStateSvgPath: projectDataEmptyStateSvgPath,
+ fullPath: projectDataFullPath,
+ isProject: parseBoolean(projectDataIsProject),
+ isSignedIn: parseBoolean(projectDataIsSignedIn),
+ serviceDeskEmailAddress,
+ canAdminIssues: parseBoolean(canAdminIssues),
+ canEditProjectSettings: parseBoolean(canEditProjectSettings),
+ serviceDeskCalloutSvgPath,
+ serviceDeskSettingsPath,
+ serviceDeskHelpPath,
+ isServiceDeskSupported: parseBoolean(isServiceDeskSupported),
+ isServiceDeskEnabled: parseBoolean(isServiceDeskEnabled),
+ hasAnyIssues: parseBoolean(projectDataHasAnyIssues),
+ },
+ render: (createComponent) => createComponent(ServiceDeskListApp),
+ });
+}
diff --git a/app/assets/javascripts/service_desk/queries/get_service_desk_issues.query.graphql b/app/assets/javascripts/service_desk/queries/get_service_desk_issues.query.graphql
new file mode 100644
index 00000000000..c678b8dd8ab
--- /dev/null
+++ b/app/assets/javascripts/service_desk/queries/get_service_desk_issues.query.graphql
@@ -0,0 +1,72 @@
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+#import "./issue.fragment.graphql"
+
+query getServiceDeskIssues(
+ $hideUsers: Boolean = false
+ $isProject: Boolean = false
+ $isSignedIn: Boolean = false
+ $fullPath: ID!
+ $iid: String
+ $search: String
+ $sort: IssueSort
+ $state: IssuableState
+ $in: [IssuableSearchableField!]
+ $assigneeId: String
+ $assigneeUsernames: [String!]
+ $authorUsername: String
+ $confidential: Boolean
+ $labelName: [String]
+ $milestoneTitle: [String]
+ $milestoneWildcardId: MilestoneWildcardId
+ $myReactionEmoji: String
+ $releaseTag: [String!]
+ $releaseTagWildcardId: ReleaseTagWildcardId
+ $types: [IssueType!]
+ $crmContactId: String
+ $crmOrganizationId: String
+ $not: NegatedIssueFilterInput
+ $or: UnionedIssueFilterInput
+ $beforeCursor: String
+ $afterCursor: String
+ $firstPageSize: Int
+ $lastPageSize: Int
+) {
+ project(fullPath: $fullPath) @include(if: $isProject) @persist {
+ id
+ issues(
+ iid: $iid
+ search: $search
+ sort: $sort
+ state: $state
+ in: $in
+ assigneeId: $assigneeId
+ assigneeUsernames: $assigneeUsernames
+ authorUsername: $authorUsername
+ confidential: $confidential
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ milestoneWildcardId: $milestoneWildcardId
+ myReactionEmoji: $myReactionEmoji
+ releaseTag: $releaseTag
+ releaseTagWildcardId: $releaseTagWildcardId
+ types: $types
+ crmContactId: $crmContactId
+ crmOrganizationId: $crmOrganizationId
+ not: $not
+ or: $or
+ before: $beforeCursor
+ after: $afterCursor
+ first: $firstPageSize
+ last: $lastPageSize
+ ) {
+ __persist
+ pageInfo {
+ ...PageInfo
+ }
+ nodes {
+ __persist
+ ...IssueFragment
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/service_desk/queries/get_service_desk_issues_counts.query.graphql b/app/assets/javascripts/service_desk/queries/get_service_desk_issues_counts.query.graphql
new file mode 100644
index 00000000000..c2ba397d76f
--- /dev/null
+++ b/app/assets/javascripts/service_desk/queries/get_service_desk_issues_counts.query.graphql
@@ -0,0 +1,91 @@
+query getServiceDeskIssuesCount(
+ $isProject: Boolean = false
+ $fullPath: ID!
+ $iid: String
+ $search: String
+ $assigneeId: String
+ $assigneeUsernames: [String!]
+ $authorUsername: String
+ $confidential: Boolean
+ $labelName: [String]
+ $milestoneTitle: [String]
+ $milestoneWildcardId: MilestoneWildcardId
+ $myReactionEmoji: String
+ $releaseTag: [String!]
+ $releaseTagWildcardId: ReleaseTagWildcardId
+ $types: [IssueType!]
+ $crmContactId: String
+ $crmOrganizationId: String
+ $not: NegatedIssueFilterInput
+ $or: UnionedIssueFilterInput
+) {
+ project(fullPath: $fullPath) @include(if: $isProject) {
+ id
+ openedIssues: issues(
+ state: opened
+ iid: $iid
+ search: $search
+ assigneeId: $assigneeId
+ assigneeUsernames: $assigneeUsernames
+ authorUsername: $authorUsername
+ confidential: $confidential
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ milestoneWildcardId: $milestoneWildcardId
+ myReactionEmoji: $myReactionEmoji
+ releaseTag: $releaseTag
+ releaseTagWildcardId: $releaseTagWildcardId
+ types: $types
+ crmContactId: $crmContactId
+ crmOrganizationId: $crmOrganizationId
+ not: $not
+ or: $or
+ ) {
+ count
+ }
+ closedIssues: issues(
+ state: closed
+ iid: $iid
+ search: $search
+ assigneeId: $assigneeId
+ assigneeUsernames: $assigneeUsernames
+ authorUsername: $authorUsername
+ confidential: $confidential
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ milestoneWildcardId: $milestoneWildcardId
+ myReactionEmoji: $myReactionEmoji
+ releaseTag: $releaseTag
+ releaseTagWildcardId: $releaseTagWildcardId
+ types: $types
+ crmContactId: $crmContactId
+ crmOrganizationId: $crmOrganizationId
+ not: $not
+ or: $or
+ ) {
+ count
+ }
+ allIssues: issues(
+ state: all
+ iid: $iid
+ search: $search
+ assigneeId: $assigneeId
+ assigneeUsernames: $assigneeUsernames
+ authorUsername: $authorUsername
+ confidential: $confidential
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ milestoneWildcardId: $milestoneWildcardId
+ myReactionEmoji: $myReactionEmoji
+ releaseTag: $releaseTag
+ releaseTagWildcardId: $releaseTagWildcardId
+ types: $types
+ crmContactId: $crmContactId
+ crmOrganizationId: $crmOrganizationId
+ not: $not
+ or: $or
+ ) {
+ count
+ }
+ }
+}
diff --git a/app/assets/javascripts/service_desk/queries/issue.fragment.graphql b/app/assets/javascripts/service_desk/queries/issue.fragment.graphql
new file mode 100644
index 00000000000..3b49c0efb14
--- /dev/null
+++ b/app/assets/javascripts/service_desk/queries/issue.fragment.graphql
@@ -0,0 +1,60 @@
+fragment IssueFragment on Issue {
+ id
+ iid
+ confidential
+ createdAt
+ downvotes
+ dueDate
+ hidden
+ humanTimeEstimate
+ mergeRequestsCount
+ moved
+ state
+ title
+ updatedAt
+ closedAt
+ upvotes
+ userDiscussionsCount @include(if: $isSignedIn)
+ webPath
+ webUrl
+ type
+ assignees @skip(if: $hideUsers) {
+ nodes {
+ __persist
+ id
+ avatarUrl
+ name
+ username
+ webUrl
+ }
+ }
+ author @skip(if: $hideUsers) {
+ __persist
+ id
+ avatarUrl
+ name
+ username
+ webUrl
+ }
+ labels {
+ nodes {
+ __persist
+ id
+ color
+ title
+ description
+ }
+ }
+ milestone {
+ __persist
+ id
+ dueDate
+ startDate
+ webPath
+ title
+ }
+ taskCompletionStatus {
+ completedCount
+ count
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
index 2c6eb0e5001..820d2e94016 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
@@ -2,46 +2,8 @@
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { __ } from '~/locale';
-import { isUserBusy } from '~/set_status_modal/utils';
import AssigneeAvatar from './assignee_avatar.vue';
-const I18N = {
- BUSY: __('Busy'),
- CANNOT_MERGE: __('Cannot merge'),
- LC_CANNOT_MERGE: __('cannot merge'),
-};
-
-const paranthesize = (str) => `(${str})`;
-
-const generateAssigneeTooltip = ({
- name,
- availability,
- cannotMerge = true,
- tooltipHasName = false,
-}) => {
- if (!tooltipHasName) {
- return cannotMerge ? I18N.CANNOT_MERGE : '';
- }
-
- const statusInformation = [];
- if (availability && isUserBusy(availability)) {
- statusInformation.push(I18N.BUSY);
- }
-
- if (cannotMerge) {
- statusInformation.push(I18N.LC_CANNOT_MERGE);
- }
-
- if (tooltipHasName && statusInformation.length) {
- const status = statusInformation.map(paranthesize).join(' ');
-
- return `${name} ${status}`;
- }
-
- return name;
-};
-
export default {
components: {
AssigneeAvatar,
@@ -55,16 +17,6 @@ export default {
type: Object,
required: true,
},
- tooltipPlacement: {
- type: String,
- default: 'bottom',
- required: false,
- },
- tooltipHasName: {
- type: Boolean,
- default: true,
- required: false,
- },
issuableType: {
type: String,
default: TYPE_ISSUE,
@@ -79,34 +31,10 @@ export default {
const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge;
return this.isMergeRequest && !canMerge;
},
- tooltipTitle() {
- const { name = '', availability = '' } = this.user;
- return generateAssigneeTooltip({
- name,
- availability,
- cannotMerge: this.cannotMerge,
- tooltipHasName: this.tooltipHasName,
- });
- },
- tooltipOption() {
- if (this.isMergeRequest) {
- return null;
- }
-
- return {
- container: 'body',
- placement: this.tooltipPlacement,
- boundary: 'viewport',
- };
- },
assigneeUrl() {
return this.user.web_url || this.user.webUrl;
},
assigneeId() {
- if (this.isMergeRequest) {
- return null;
- }
-
return isGid(this.user.id) ? getIdFromGraphQLId(this.user.id) : this.user.id;
},
},
@@ -116,10 +44,10 @@ export default {
<template>
<!-- must be `d-inline-block` or parent flex-basis causes width issues -->
<gl-link
- v-gl-tooltip="tooltipOption"
:href="assigneeUrl"
- :title="tooltipTitle"
:data-user-id="assigneeId"
+ :data-username="user.username"
+ :data-cannot-merge="cannotMerge"
data-placement="left"
class="gl-display-inline-block js-user-link"
>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
index 2a9100f0cb5..609a9355d20 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
@@ -1,15 +1,12 @@
<script>
-import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
+import { GlLoadingIcon } from '@gitlab/ui';
import { n__, __ } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'AssigneeTitle',
components: {
GlLoadingIcon,
- GlIcon,
},
- mixins: [glFeatureFlagMixin()],
props: {
loading: {
type: Boolean,
@@ -24,11 +21,6 @@ export default {
type: Boolean,
required: true,
},
- showToggle: {
- type: Boolean,
- required: false,
- default: false,
- },
changing: {
type: Boolean,
required: false,
@@ -62,15 +54,5 @@ export default {
>
{{ titleCopy }}
</a>
- <a
- v-if="showToggle"
- :aria-label="__('Toggle sidebar')"
- class="gutter-toggle float-right js-sidebar-toggle"
- :class="{ 'gl-display-block gl-md-display-none!': glFeatures.movedMrSidebar }"
- href="#"
- role="button"
- >
- <gl-icon data-hidden="true" name="chevron-double-lg-right" :size="12" />
- </a>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
index 884edc97016..577c01c50ff 100644
--- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
@@ -17,7 +17,7 @@ const generateCollapsedAssigneeTooltip = ({ renderUsers, allUsers, tooltipTitleM
});
if (!allUsers.length) {
- return __('Assignee(s)');
+ return __('Assignees');
}
if (allUsers.length > names.length) {
names.push(sprintf(__('+ %{amount} more'), { amount: allUsers.length - names.length }));
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index 062f63175a7..0563ed8394c 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -27,11 +27,6 @@ export default {
type: String,
required: true,
},
- signedIn: {
- type: Boolean,
- required: false,
- default: false,
- },
issuableType: {
type: String,
required: false,
@@ -143,7 +138,6 @@ export default {
:number-of-assignees="store.assignees.length"
:loading="loading || store.isFetching.assignees"
:editable="store.editable"
- :show-toggle="!signedIn"
:changing="store.changing"
/>
<assignees
diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
index b424d9074d0..930e7ff12d9 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -77,14 +77,13 @@ export default {
v-for="(user, index) in uncollapsedUsers"
:key="user.id"
:class="{
- 'gl-mb-3': index !== users.length - 1,
+ 'gl-mb-3': index !== users.length - 1 || users.length > 5,
}"
class="assignee-grid gl-display-grid gl-align-items-center gl-w-full"
>
<assignee-avatar-link
:user="user"
:issuable-type="issuableType"
- :tooltip-has-name="!isMergeRequest"
class="gl-word-break-word"
data-css-area="user"
>
diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue
index 916ff70a5ea..398a94356e2 100644
--- a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue
+++ b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue
@@ -4,10 +4,12 @@ import { __, n__, sprintf } from '~/locale';
import { createAlert } from '~/alert';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_ISSUE } from '~/graphql_shared/constants';
+import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility';
import getIssueCrmContactsQuery from '../../queries/get_issue_crm_contacts.query.graphql';
import issueCrmContactsSubscription from '../../queries/issue_crm_contacts.subscription.graphql';
export default {
+ crmDocsLink: `${DOCS_URL_IN_EE_DIR}/user/crm/`,
components: {
GlIcon,
GlLink,
@@ -104,9 +106,7 @@ export default {
<span> {{ contactCount }} </span>
</div>
<div class="hide-collapsed help-button gl-float-right">
- <gl-link href="https://docs.gitlab.com/ee/user/crm/" target="_blank"
- ><gl-icon name="question-o"
- /></gl-link>
+ <gl-link :href="$options.crmDocsLink" target="_blank"><gl-icon name="question-o" /></gl-link>
</div>
<div class="title hide-collapsed gl-mb-2 gl-line-height-20 gl-font-weight-bold">
{{ contactsLabel }}
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue
index 89a976d45fa..1c27df2418d 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue
@@ -40,7 +40,6 @@ export default {
<div
class="labels-select-dropdown-contents gl-w-full gl-my-2 gl-py-3 gl-rounded-base gl-absolute"
data-testid="labels-select-dropdown-contents"
- data-qa-selector="labels_dropdown_content"
:style="directionStyle"
>
<component :is="dropdownContentsView" />
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue
index b44096c7743..53582aacabd 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue
@@ -20,6 +20,11 @@ export default {
GlDropdownItem,
GlLink,
},
+ inject: {
+ toggleAttrs: {
+ default: () => ({}),
+ },
+ },
props: {
labelsCreateTitle: {
type: String,
@@ -204,7 +209,7 @@ export default {
class="gl-w-full"
block
data-testid="labels-select-dropdown-contents"
- data-qa-selector="labels_dropdown_content"
+ :toggle-attrs="toggleAttrs"
@hide="handleDropdownHide"
@shown="setFocus"
>
@@ -219,7 +224,7 @@ export default {
@toggleDropdownContentsCreateView="toggleDropdownContent"
@closeDropdown="hideDropdown"
@input="debouncedSearchKeyUpdate"
- @searchEnter="selectFirstItem"
+ @searchEnter.prevent="selectFirstItem"
/>
</template>
<template #default>
diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
index 1d9233db361..1ea8ab19012 100644
--- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
@@ -19,12 +19,12 @@ export default {
locked: {
icon: 'lock',
class: 'value',
- iconClass: 'is-active',
+ displayText: __('Locked'),
},
unlocked: {
class: ['no-value hide-collapsed'],
icon: 'lock-open',
- iconClass: '',
+ displayText: __('Unlocked'),
},
components: {
EditForm,
@@ -49,8 +49,6 @@ export default {
issueCapitalized: __('Issue'),
mergeRequest: __('merge request'),
mergeRequestCapitalized: __('Merge request'),
- locked: __('Locked'),
- unlocked: __('Unlocked'),
lockingMergeRequest: __('Locking %{issuableDisplayName}'),
unlockingMergeRequest: __('Unlocking %{issuableDisplayName}'),
lockMergeRequest: __('Lock %{issuableDisplayName}'),
@@ -84,10 +82,7 @@ export default {
return this.getNoteableData.discussion_locked;
},
lockStatus() {
- return this.isLocked ? this.$options.i18n.locked : this.$options.i18n.unlocked;
- },
- tooltipLabel() {
- return this.isLocked ? this.$options.i18n.locked : this.$options.i18n.unlocked;
+ return this.isLocked ? this.$options.locked : this.$options.unlocked;
},
lockToggleInProgressText() {
return this.isLocked ? this.unlockingMergeRequestText : this.lockingMergeRequestText;
@@ -205,7 +200,7 @@ export default {
</gl-disclosure-dropdown-item>
<div v-else class="block issuable-sidebar-item lock">
<div
- v-gl-tooltip.left.viewport="{ title: tooltipLabel }"
+ v-gl-tooltip.left.viewport="{ title: lockStatus.displayText }"
class="sidebar-collapsed-icon"
data-testid="sidebar-collapse-icon"
@click="toggleForm"
@@ -239,7 +234,7 @@ export default {
/>
<div data-testid="lock-status" class="sidebar-item-value" :class="lockStatus.class">
- {{ lockStatus }}
+ {{ lockStatus.displayText }}
</div>
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index bbd3cda0ad3..bad73273409 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, n__, sprintf } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
export default {
@@ -81,6 +82,9 @@ export default {
toggleMoreParticipants() {
this.isShowingMoreParticipants = !this.isShowingMoreParticipants;
},
+ getParticipantId(participantId) {
+ return getIdFromGraphQLId(participantId);
+ },
onClickCollapsedIcon() {
this.$emit('toggleSidebar');
},
@@ -118,13 +122,14 @@ export default {
>
<a
:href="participant.web_url || participant.webUrl"
- class="author-link gl-display-inline-block gl-rounded-full"
+ :data-user-id="getParticipantId(participant.id)"
+ :data-username="participant.username"
+ class="author-link js-user-link gl-display-inline-block gl-rounded-full"
>
<user-avatar-image
:lazy="lazy"
:img-src="participant.avatar_url || participant.avatarUrl"
:size="24"
- :tooltip-text="participant.name"
:img-alt="participant.name"
css-classes="gl-mr-0!"
tooltip-placement="bottom"
diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
index 6f82178b6fd..88a74784dd2 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
@@ -67,7 +67,7 @@ export default {
const names = renderUsers.map((u) => u.name);
if (!this.users.length) {
- return __('Reviewer(s)');
+ return __('Reviewers');
}
if (this.users.length > names.length) {
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
index 80c051f86b5..01787c97bca 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
@@ -3,7 +3,7 @@
// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
-import { __, sprintf } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import ReviewerAvatar from './reviewer_avatar.vue';
export default {
@@ -23,16 +23,6 @@ export default {
type: String,
required: true,
},
- tooltipPlacement: {
- type: String,
- default: 'bottom',
- required: false,
- },
- tooltipHasName: {
- type: Boolean,
- default: true,
- required: false,
- },
issuableType: {
type: String,
default: TYPE_ISSUE,
@@ -45,21 +35,8 @@ export default {
this.issuableType === TYPE_MERGE_REQUEST && !this.user.mergeRequestInteraction?.canMerge
);
},
- tooltipTitle() {
- if (this.cannotMerge && this.tooltipHasName) {
- return sprintf(__('%{userName} (cannot merge)'), { userName: this.user.name });
- } else if (this.cannotMerge) {
- return __('Cannot merge');
- }
-
- return '';
- },
- tooltipOption() {
- return {
- container: 'body',
- placement: this.tooltipPlacement,
- boundary: 'viewport',
- };
+ reviewerId() {
+ return getIdFromGraphQLId(this.user.id);
},
reviewerUrl() {
return this.user.webUrl;
@@ -71,9 +48,11 @@ export default {
<template>
<!-- must be `d-inline-block` or parent flex-basis causes width issues -->
<gl-link
- v-gl-tooltip="tooltipOption"
:href="reviewerUrl"
- :title="tooltipTitle"
+ :data-user-id="reviewerId"
+ :data-username="user.username"
+ :data-cannot-merge="cannotMerge"
+ data-placement="left"
class="gl-display-inline-block js-user-link"
>
<!-- use d-flex so that slot can be appropriately styled -->
diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue
index 55de0ceb388..e2a3efa096f 100644
--- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue
+++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDropdownItem, GlTooltip, GlSprintf } from '@gitlab/ui';
+import { GlCollapsibleListbox, GlTooltip, GlSprintf } from '@gitlab/ui';
import { createAlert } from '~/alert';
import { TYPE_INCIDENT } from '~/issues/constants';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
@@ -12,8 +12,7 @@ export default {
components: {
GlTooltip,
GlSprintf,
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
SeverityToken,
SidebarEditableItem,
},
@@ -57,6 +56,13 @@ export default {
return [];
}
},
+ dropdownItems() {
+ return this.severitiesList.map((severity) => ({
+ text: severity.label,
+ value: severity.value,
+ severity,
+ }));
+ },
selectedItem() {
return this.severitiesList.find((severity) => severity.value === this.severity);
},
@@ -99,7 +105,7 @@ export default {
});
},
showDropdown() {
- this.$refs.dropdown.show();
+ this.$refs.dropdown.open();
},
},
};
@@ -131,24 +137,20 @@ export default {
</template>
<template #default>
- <gl-dropdown
+ <gl-collapsible-listbox
ref="dropdown"
class="gl-mt-3"
block
:header-text="__('Assign severity')"
- :text="selectedItem.label"
+ :toggle-text="selectedItem.label"
+ :items="dropdownItems"
+ :selected="severity"
+ @select="updateSeverity"
>
- <gl-dropdown-item
- v-for="option in severitiesList"
- :key="option.value"
- data-testid="severityDropdownItem"
- is-check-item
- :is-checked="option.value === severity"
- @click="updateSeverity(option.value)"
- >
- <severity-token :severity="option" />
- </gl-dropdown-item>
- </gl-dropdown>
+ <template #list-item="{ item }">
+ <severity-token :severity="item.severity" />
+ </template>
+ </gl-collapsible-listbox>
</template>
</sidebar-editable-item>
</div>
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 67e76b575e0..8f6b855ecd6 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -118,7 +118,6 @@ function mountSidebarAssigneesDeprecated(mediator) {
issuableIid: String(iid),
projectPath: fullPath,
field: el.dataset.field,
- signedIn: Object.prototype.hasOwnProperty.call(el.dataset, 'signedIn'),
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js
index 2d2eede9137..a4653b75bab 100644
--- a/app/assets/javascripts/snippets/constants.js
+++ b/app/assets/javascripts/snippets/constants.js
@@ -21,6 +21,9 @@ export const SNIPPET_VISIBILITY = {
label: __('Public'),
icon: 'earth',
description: __('The snippet can be accessed without any authentication.'),
+ description_project: __(
+ 'The snippet can be accessed without any authentication. To embed snippets, a project must be public.',
+ ),
},
};
diff --git a/app/assets/javascripts/super_sidebar/components/brand_logo.vue b/app/assets/javascripts/super_sidebar/components/brand_logo.vue
index c017fa8afa2..66381e4da4d 100644
--- a/app/assets/javascripts/super_sidebar/components/brand_logo.vue
+++ b/app/assets/javascripts/super_sidebar/components/brand_logo.vue
@@ -27,7 +27,7 @@ export default {
<template>
<a
v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.homepage"
- class="tanuki-logo-container"
+ class="brand-logo"
:href="rootPath"
:title="$options.i18n.homepage"
data-track-action="click_link"
diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue
index 0ce856c9af8..82f4fd18e80 100644
--- a/app/assets/javascripts/super_sidebar/components/create_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue
@@ -11,10 +11,11 @@ import {
TOP_NAV_INVITE_MEMBERS_COMPONENT,
TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN,
} from '~/invite_members/constants';
-import { DROPDOWN_Y_OFFSET } from '../constants';
+import { DROPDOWN_Y_OFFSET, IMPERSONATING_OFFSET } from '../constants';
// Left offset required for the dropdown to be aligned with the super sidebar
-const DROPDOWN_X_OFFSET = -147;
+const DROPDOWN_X_OFFSET_BASE = -147;
+const DROPDOWN_X_OFFSET_IMPERSONATING = DROPDOWN_X_OFFSET_BASE + IMPERSONATING_OFFSET;
export default {
components: {
@@ -27,6 +28,7 @@ export default {
i18n: {
createNew: __('Create new...'),
},
+ inject: ['isImpersonating'],
props: {
groups: {
type: Array,
@@ -38,13 +40,20 @@ export default {
dropdownOpen: false,
};
},
+ computed: {
+ dropdownOffset() {
+ return {
+ mainAxis: DROPDOWN_Y_OFFSET,
+ crossAxis: this.isImpersonating ? DROPDOWN_X_OFFSET_IMPERSONATING : DROPDOWN_X_OFFSET_BASE,
+ };
+ },
+ },
methods: {
isInvitedMembers(groupItem) {
return groupItem.component === TOP_NAV_INVITE_MEMBERS_COMPONENT;
},
},
toggleId: 'create-menu-toggle',
- dropdownOffset: { mainAxis: DROPDOWN_Y_OFFSET, crossAxis: DROPDOWN_X_OFFSET },
TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN,
};
</script>
@@ -58,7 +67,7 @@ export default {
text-sr-only
:toggle-text="$options.i18n.createNew"
:toggle-id="$options.toggleId"
- :dropdown-offset="$options.dropdownOffset"
+ :dropdown-offset="dropdownOffset"
data-qa-selector="new_menu_toggle"
data-testid="new-menu-toggle"
@shown="dropdownOpen = true"
diff --git a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
index 02adebc50af..342e1284e86 100644
--- a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
@@ -105,7 +105,6 @@ export default {
icon="dash"
:aria-label="$options.i18n.removeItem"
:title="$options.i18n.removeItem"
- class="gl-align-self-center gl-mr-2"
data-testid="item-remove"
@click.stop.prevent="handleItemRemove(item)"
/>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue
index 96e6c9bab9e..a1d0e400b5f 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue
@@ -2,21 +2,25 @@
import { debounce } from 'lodash';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { GlDisclosureDropdownGroup, GlLoadingIcon } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import axios from '~/lib/utils/axios_utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { getFormattedItem } from '../utils';
+
import {
COMMON_HANDLES,
COMMAND_HANDLE,
USER_HANDLE,
PROJECT_HANDLE,
ISSUE_HANDLE,
- GLOBAL_COMMANDS_GROUP_TITLE,
+ PATH_HANDLE,
PAGES_GROUP_TITLE,
+ PATH_GROUP_TITLE,
GROUP_TITLES,
+ MAX_ROWS,
} from './constants';
import SearchItem from './search_item.vue';
-import { commandMapper, linksReducer, autocompleteQuery } from './utils';
+import { commandMapper, linksReducer, autocompleteQuery, fileMapper } from './utils';
export default {
name: 'CommandPaletteItems',
@@ -25,7 +29,14 @@ export default {
GlLoadingIcon,
SearchItem,
},
- inject: ['commandPaletteCommands', 'commandPaletteLinks', 'autocompletePath', 'searchContext'],
+ inject: [
+ 'commandPaletteCommands',
+ 'commandPaletteLinks',
+ 'autocompletePath',
+ 'searchContext',
+ 'projectFilesPath',
+ 'projectBlobPath',
+ ],
props: {
searchQuery: {
type: String,
@@ -35,21 +46,45 @@ export default {
type: String,
required: true,
validator: (value) => {
- return COMMON_HANDLES.includes(value);
+ return [...COMMON_HANDLES, PATH_HANDLE].includes(value);
},
},
},
data: () => ({
groups: [],
- error: null,
loading: false,
+ projectFiles: [],
+ debouncedSearch: debounce(function debouncedSearch() {
+ switch (this.handle) {
+ case COMMAND_HANDLE:
+ this.getCommandsAndPages();
+ break;
+ /* TODO: Search for recent issues initiated by #(ISSUE_HANDLE) from the command palette scope
+ was removed as using the # in command palette conflicted
+ with the existing global search functionality to search for issue by its id.
+ The code that performs the Recent issues search was not removed from the code base
+ as it would be nice to bring it back when we decide how to combine both search by id and text.
+ In scope of https://gitlab.com/gitlab-org/gitlab/-/issues/417434
+ we either bring back the search by #issue_text or remove the related code completely */
+ case USER_HANDLE:
+ case PROJECT_HANDLE:
+ case ISSUE_HANDLE:
+ this.getScopedItems();
+ break;
+ case PATH_HANDLE:
+ this.getProjectFiles();
+ break;
+ default:
+ break;
+ }
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
}),
computed: {
isCommandMode() {
return this.handle === COMMAND_HANDLE;
},
- isUserMode() {
- return this.handle === USER_HANDLE;
+ isPathMode() {
+ return this.handle === PATH_HANDLE;
},
commands() {
return this.commandPaletteCommands.map(commandMapper);
@@ -62,7 +97,7 @@ export default {
? this.commands
.map(({ name, items }) => {
return {
- name: name || GLOBAL_COMMANDS_GROUP_TITLE,
+ name,
items: this.filterBySearchQuery(items, 'text'),
};
})
@@ -73,7 +108,7 @@ export default {
return this.groups?.length && this.groups.some((group) => group.items?.length);
},
hasSearchQuery() {
- if (this.isCommandMode) {
+ if (this.isCommandMode || this.isPathMode) {
return this.searchQuery?.length > 0;
}
return this.searchQuery?.length > 2;
@@ -84,44 +119,58 @@ export default {
}
return this.searchQuery;
},
+ filteredProjectFiles() {
+ if (!this.searchQuery) {
+ return this.projectFiles.slice(0, MAX_ROWS);
+ }
+ return this.filterBySearchQuery(this.projectFiles, 'text').slice(0, MAX_ROWS);
+ },
},
watch: {
searchQuery: {
handler() {
- switch (this.handle) {
- case COMMAND_HANDLE:
- this.getCommandsAndPages();
- break;
- case USER_HANDLE:
- case PROJECT_HANDLE:
- case ISSUE_HANDLE:
- this.getScopedItems();
- break;
- default:
- break;
- }
+ this.debouncedSearch();
},
immediate: true,
},
},
+ updated() {
+ this.$emit('updated');
+ },
methods: {
filterBySearchQuery(items, key = 'keywords') {
return fuzzaldrinPlus.filter(items, this.searchQuery, { key });
},
+ async getProjectFiles() {
+ if (!this.projectFiles.length) {
+ this.loading = true;
+
+ try {
+ const response = await axios.get(this.projectFilesPath);
+ this.projectFiles = response?.data.map(fileMapper.bind(null, this.projectBlobPath));
+ } catch (error) {
+ Sentry.captureException(error);
+ } finally {
+ this.loading = false;
+ }
+ }
+
+ this.groups = [
+ {
+ name: PATH_GROUP_TITLE,
+ items: this.filteredProjectFiles,
+ },
+ ];
+ },
getCommandsAndPages() {
if (!this.searchQuery) {
this.groups = [...this.commands];
return;
}
- const matchedLinks = this.filterBySearchQuery(this.links);
- if (this.filteredCommands.length || matchedLinks.length) {
- this.groups = [];
- }
+ this.groups = [...this.filteredCommands];
- if (this.filteredCommands.length) {
- this.groups = [...this.filteredCommands];
- }
+ const matchedLinks = this.filterBySearchQuery(this.links);
if (matchedLinks.length) {
this.groups.push({
@@ -130,62 +179,57 @@ export default {
});
}
},
- getScopedItems: debounce(function debouncedSearch() {
- if (this.searchQuery && this.searchQuery.length < 3) return null;
+ async getScopedItems() {
+ if (this.searchQuery && this.searchQuery.length < 3) return;
this.loading = true;
- return axios
- .get(
+ try {
+ const response = await axios.get(
autocompleteQuery({
path: this.autocompletePath,
searchTerm: this.searchTerm,
handle: this.handle,
projectId: this.searchContext.project?.id,
}),
- )
- .then(({ data }) => {
- this.groups = this.getGroups(data);
- })
- .catch((error) => {
- this.error = error;
- })
- .finally(() => {
- this.loading = false;
- });
- }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
- getGroups(data) {
- return [
- {
- name: GROUP_TITLES[this.handle],
- items: data.map(getFormattedItem),
- },
- ];
+ );
+
+ this.groups = [
+ {
+ name: GROUP_TITLES[this.handle],
+ items: response.data.map(getFormattedItem),
+ },
+ ];
+ } catch (error) {
+ Sentry.captureException(error);
+ } finally {
+ this.loading = false;
+ }
},
},
};
</script>
<template>
- <ul class="gl-p-0 gl-m-0 gl-list-style-none">
+ <div>
<gl-loading-icon v-if="loading" size="lg" class="gl-my-5" />
- <template v-else-if="hasResults">
+ <ul v-else-if="hasResults" class="gl-p-0 gl-m-0 gl-list-style-none">
<gl-disclosure-dropdown-group
v-for="(group, index) in groups"
:key="index"
:group="group"
bordered
- class="{'gl-mt-0!': index===0}"
+ :class="{ 'gl-mt-0!': index === 0 }"
>
<template #list-item="{ item }">
<search-item :item="item" :search-query="searchQuery" />
</template>
</gl-disclosure-dropdown-group>
- </template>
+ </ul>
<div v-else-if="hasSearchQuery && !hasResults" class="gl-text-gray-700 gl-pl-5 gl-py-3">
{{ __('No results found') }}
</div>
- </ul>
+ </div>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js
index 9dab16984f5..a43e621da44 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js
@@ -2,19 +2,21 @@ import { s__, sprintf } from '~/locale';
export const COMMAND_HANDLE = '>';
export const USER_HANDLE = '@';
-export const PROJECT_HANDLE = '&';
+export const PROJECT_HANDLE = ':';
export const ISSUE_HANDLE = '#';
+export const PATH_HANDLE = '/';
-export const COMMON_HANDLES = [COMMAND_HANDLE, USER_HANDLE, PROJECT_HANDLE, ISSUE_HANDLE];
+export const COMMON_HANDLES = [COMMAND_HANDLE, USER_HANDLE, PROJECT_HANDLE];
export const SEARCH_OR_COMMAND_MODE_PLACEHOLDER = sprintf(
s__(
- 'CommandPalette|Type %{commandHandle} for command, %{userHandle} for user, %{projectHandle} for project, %{issueHandle} for issue or perform generic search...',
+ 'CommandPalette|Type %{commandHandle} for command, %{userHandle} for user, %{projectHandle} for project, %{pathHandle} for project file, or perform generic search...',
),
{
commandHandle: COMMAND_HANDLE,
userHandle: USER_HANDLE,
issueHandle: ISSUE_HANDLE,
projectHandle: PROJECT_HANDLE,
+ pathHandle: PATH_HANDLE,
},
false,
);
@@ -24,6 +26,7 @@ export const SEARCH_SCOPE_PLACEHOLDER = {
[USER_HANDLE]: s__('CommandPalette|user (enter at least 3 chars)'),
[PROJECT_HANDLE]: s__('CommandPalette|project (enter at least 3 chars)'),
[ISSUE_HANDLE]: s__('CommandPalette|issue (enter at least 3 chars)'),
+ [PATH_HANDLE]: s__('CommandPalette|go to project file'),
};
export const SEARCH_SCOPE = {
@@ -37,9 +40,13 @@ export const USERS_GROUP_TITLE = s__('GlobalSearch|Users');
export const PAGES_GROUP_TITLE = s__('CommandPalette|Pages');
export const PROJECTS_GROUP_TITLE = s__('GlobalSearch|Projects');
export const ISSUE_GROUP_TITLE = s__('GlobalSearch|Recent issues');
+export const PATH_GROUP_TITLE = s__('CommandPalette|Project files');
export const GROUP_TITLES = {
[USER_HANDLE]: USERS_GROUP_TITLE,
[PROJECT_HANDLE]: PROJECTS_GROUP_TITLE,
[ISSUE_HANDLE]: ISSUE_GROUP_TITLE,
+ [PATH_HANDLE]: PATH_GROUP_TITLE,
};
+
+export const MAX_ROWS = 20;
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue
index dce2b24f551..efd93e88fa9 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue
@@ -1,5 +1,5 @@
<script>
-import { COMMON_HANDLES, SEARCH_SCOPE_PLACEHOLDER } from './constants';
+import { COMMON_HANDLES, PATH_HANDLE, SEARCH_SCOPE_PLACEHOLDER } from './constants';
export default {
name: 'FakeSearchInput',
@@ -11,7 +11,7 @@ export default {
scope: {
type: String,
required: true,
- validator: (value) => COMMON_HANDLES.includes(value),
+ validator: (value) => [...COMMON_HANDLES, PATH_HANDLE].includes(value),
},
},
computed: {
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js
index 5c8c0e59eaf..347a8ffb0b4 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js
@@ -1,12 +1,12 @@
import { isNil, omitBy } from 'lodash';
-import { objectToQuery } from '~/lib/utils/url_utility';
-import { SEARCH_SCOPE } from './constants';
+import { objectToQuery, joinPaths } from '~/lib/utils/url_utility';
+import { SEARCH_SCOPE, GLOBAL_COMMANDS_GROUP_TITLE } from './constants';
export const commandMapper = ({ name, items }) => {
// TODO: we filter out invite_members for now, because it is complicated to add the invite members modal here
// and is out of scope for the basic command palette items. If it proves to be useful, we can add it later.
return {
- name,
+ name: name || GLOBAL_COMMANDS_GROUP_TITLE,
items: items.filter(({ component }) => component !== 'invite_members'),
};
};
@@ -32,6 +32,14 @@ export const linksReducer = (acc, menuItem) => {
return acc;
};
+export const fileMapper = (projectBlobPath, file) => {
+ return {
+ icon: 'doc-code',
+ text: file,
+ href: joinPaths(projectBlobPath, file),
+ };
+};
+
export const autocompleteQuery = ({ path, searchTerm, handle, projectId }) => {
const query = omitBy(
{
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
index cb34f2b8c26..bec8c191b31 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
@@ -25,20 +25,24 @@ import {
SEARCH_RESULTS_SCOPE,
} from '~/vue_shared/global_search/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { darkModeEnabled } from '~/lib/utils/color_utils';
import {
SEARCH_INPUT_DESCRIPTION,
SEARCH_RESULTS_DESCRIPTION,
SEARCH_SHORTCUTS_MIN_CHARACTERS,
SCOPE_TOKEN_MAX_LENGTH,
INPUT_FIELD_PADDING,
- IS_SEARCHING,
SEARCH_MODAL_ID,
SEARCH_INPUT_SELECTOR,
SEARCH_RESULTS_ITEM_SELECTOR,
} from '../constants';
import CommandPaletteItems from '../command_palette/command_palette_items.vue';
import FakeSearchInput from '../command_palette/fake_search_input.vue';
-import { COMMON_HANDLES, SEARCH_OR_COMMAND_MODE_PLACEHOLDER } from '../command_palette/constants';
+import {
+ COMMON_HANDLES,
+ PATH_HANDLE,
+ SEARCH_OR_COMMAND_MODE_PLACEHOLDER,
+} from '../command_palette/constants';
import GlobalSearchAutocompleteItems from './global_search_autocomplete_items.vue';
import GlobalSearchDefaultItems from './global_search_default_items.vue';
import GlobalSearchScopedItems from './global_search_scoped_items.vue';
@@ -68,6 +72,11 @@ export default {
FakeSearchInput,
},
mixins: [glFeatureFlagMixin()],
+ data() {
+ return {
+ nextFocusedItemIndex: null,
+ };
+ },
computed: {
...mapState(['search', 'loading', 'searchContext']),
...mapGetters(['searchQuery', 'searchOptions', 'scopedSearchOptions']),
@@ -108,34 +117,38 @@ export default {
count: this.searchOptions.length,
});
},
- searchBarClasses() {
- return {
- [IS_SEARCHING]: this.searchTermOverMin,
- };
- },
- showScopeHelp() {
+ showScopeToken() {
return this.searchTermOverMin && !this.isCommandMode;
},
searchBarItem() {
return this.searchOptions?.[0];
},
- infieldHelpContent() {
+ scopeTokenText() {
return this.searchBarItem?.scope || this.searchBarItem?.description;
},
- infieldHelpIcon() {
- return this.searchBarItem?.icon;
+ scopeTokenIcon() {
+ if (!this.isCommandMode) {
+ return this.searchBarItem?.icon;
+ }
+ return null;
},
- scopeTokenTitle() {
+ searchScope() {
return sprintf(this.$options.i18n.SEARCH_RESULTS_SCOPE, {
- scope: this.infieldHelpContent,
+ scope: this.scopeTokenText,
});
},
-
+ truncatedSearchScope() {
+ return truncate(this.searchScope, SCOPE_TOKEN_MAX_LENGTH);
+ },
searchTextFirstChar() {
return this.searchText?.trim().charAt(0);
},
isCommandMode() {
- return this.glFeatures?.commandPalette && COMMON_HANDLES.includes(this.searchTextFirstChar);
+ return (
+ this.glFeatures?.commandPalette &&
+ (COMMON_HANDLES.includes(this.searchTextFirstChar) ||
+ (this.searchContext.project && this.searchTextFirstChar === PATH_HANDLE))
+ );
},
commandPaletteQuery() {
if (this.isCommandMode) {
@@ -143,6 +156,14 @@ export default {
}
return '';
},
+ commandHighlightClass() {
+ return darkModeEnabled() ? 'gl-bg-gray-10!' : 'gl-bg-gray-50!';
+ },
+ },
+ watch: {
+ nextFocusedItemIndex() {
+ this.highlightFirstCommand();
+ },
},
methods: {
...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']),
@@ -156,9 +177,6 @@ export default {
this.fetchAutocompleteOptions();
}
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
- getTruncatedScope(scope) {
- return truncate(scope, SCOPE_TOKEN_MAX_LENGTH);
- },
observeTokenWidth({ contentRect: { width } }) {
const inputField = this.$refs?.searchInputBox?.$el?.querySelector('input');
if (!inputField) {
@@ -206,7 +224,7 @@ export default {
}
},
focusSearchInput() {
- this.$refs.searchInputBox.$el.querySelector('input').focus();
+ this.$refs.searchInput.$el.querySelector('input').focus();
},
focusNextItem(event, elements, offset) {
const { target } = event;
@@ -221,11 +239,34 @@ export default {
elements[index]?.focus();
},
submitSearch() {
+ if (this.isCommandMode) {
+ this.runFirstCommand();
+ return;
+ }
if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) {
return;
}
visitUrl(this.searchQuery);
},
+ runFirstCommand() {
+ this.getFocusableOptions()[0]?.firstChild.click();
+ },
+ onSearchModalShown() {
+ this.$emit('shown');
+ },
+ onSearchModalHidden() {
+ this.searchText = '';
+ this.$emit('hidden');
+ },
+ highlightFirstCommand() {
+ if (this.isCommandMode) {
+ const activeCommand = this.getFocusableOptions()[0]?.firstChild;
+ activeCommand?.classList.toggle(
+ this.commandHighlightClass,
+ Boolean(!this.nextFocusedItemIndex),
+ );
+ }
+ },
},
SEARCH_INPUT_DESCRIPTION,
SEARCH_RESULTS_DESCRIPTION,
@@ -243,24 +284,22 @@ export default {
body-class="gl-p-0!"
modal-class="global-search-modal"
:centered="false"
- @hidden="$emit('hidden')"
- @shown="$emit('shown')"
+ @shown="onSearchModalShown"
+ @hide="onSearchModalHidden"
>
<form
role="search"
:aria-label="searchPlaceholder"
class="gl-relative gl-rounded-base gl-w-full"
- :class="searchBarClasses"
data-testid="global-search-form"
>
<div class="gl-p-1 gl-relative">
<gl-search-box-by-type
id="search"
- ref="searchInputBox"
+ ref="searchInput"
v-model="searchText"
role="searchbox"
data-testid="global-search-input"
- data-qa-selector="global_search_input"
autocomplete="off"
:placeholder="searchPlaceholder"
:aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
@@ -270,24 +309,20 @@ export default {
@keydown="onKeydown"
/>
<gl-token
- v-if="showScopeHelp"
+ v-if="showScopeToken"
v-gl-resize-observer-directive="observeTokenWidth"
- class="in-search-scope-help gl-sm-display-block gl-display-none"
+ class="search-scope-help gl-absolute gl-sm-display-block gl-display-none"
view-only
- :title="scopeTokenTitle"
+ :title="searchScope"
>
<gl-icon
- v-if="infieldHelpIcon"
+ v-if="scopeTokenIcon"
class="gl-mr-2"
- :aria-label="infieldHelpContent"
- :name="infieldHelpIcon"
+ :aria-label="scopeTokenText"
+ :name="scopeTokenIcon"
:size="16"
/>
- {{
- getTruncatedScope(
- sprintf($options.i18n.SEARCH_RESULTS_SCOPE, { scope: infieldHelpContent }),
- )
- }}
+ {{ truncatedSearchScope }}
</gl-token>
<span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">
{{ $options.i18n.SEARCH_DESCRIBED_BY_WITH_RESULTS }}
@@ -319,6 +354,7 @@ export default {
v-if="isCommandMode"
:search-query="commandPaletteQuery"
:handle="searchTextFirstChar"
+ @updated="highlightFirstCommand"
/>
<template v-else>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/constants.js
index cb267df6122..5a860fcd1ab 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/constants.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/constants.js
@@ -18,8 +18,6 @@ export const SCOPE_TOKEN_MAX_LENGTH = 36;
export const INPUT_FIELD_PADDING = 84;
-export const IS_SEARCHING = 'is-searching';
-
export const FETCH_TYPES = ['generic', 'search'];
export const SEARCH_MODAL_ID = 'super-sidebar-search-modal';
diff --git a/app/assets/javascripts/super_sidebar/components/help_center.vue b/app/assets/javascripts/super_sidebar/components/help_center.vue
index 1d4c24c6853..8ce82116194 100644
--- a/app/assets/javascripts/super_sidebar/components/help_center.vue
+++ b/app/assets/javascripts/super_sidebar/components/help_center.vue
@@ -38,7 +38,7 @@ export default {
shortcuts: __('Keyboard shortcuts'),
version: __('Your GitLab version'),
whatsnew: __("What's new"),
- chat: s__('TanukiBot|Ask GitLab Chat'),
+ chat: s__('TanukiBot|Ask GitLab Duo'),
},
props: {
sidebarData: {
diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue
index 7d5af883651..764db490751 100644
--- a/app/assets/javascripts/super_sidebar/components/items_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/items_list.vue
@@ -19,13 +19,7 @@ export default {
<template>
<ul class="gl-p-0 gl-list-style-none">
- <nav-item
- v-for="item in items"
- :key="item.id"
- :item="item"
- :link-classes="{ 'gl-py-2!': true }"
- is-subitem
- >
+ <nav-item v-for="item in items" :key="item.id" :item="item" is-subitem>
<template #icon>
<project-avatar
:project-id="item.id"
@@ -33,7 +27,6 @@ export default {
:project-avatar-url="item.avatar"
:size="24"
aria-hidden="true"
- class="gl-mr-n2"
/>
</template>
<template #actions>
diff --git a/app/assets/javascripts/super_sidebar/components/menu_section.vue b/app/assets/javascripts/super_sidebar/components/menu_section.vue
index b5a8241a286..73a899eeb83 100644
--- a/app/assets/javascripts/super_sidebar/components/menu_section.vue
+++ b/app/assets/javascripts/super_sidebar/components/menu_section.vue
@@ -71,7 +71,7 @@ export default {
<component :is="tag">
<hr v-if="separated" aria-hidden="true" class="gl-mx-4 gl-my-2" />
<button
- class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-line-height-normal gl-mb-2 gl-py-3 gl-px-0 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left gl-w-full gl-focus--focus"
+ class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-2 gl-py-2 gl-px-3 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left gl-w-full gl-focus--focus"
:class="computedLinkClasses"
data-qa-selector="menu_section_button"
:data-qa-section-name="item.title"
@@ -84,17 +84,17 @@ export default {
aria-hidden="true"
style="width: 3px; border-radius: 3px; margin-right: 1px"
></span>
- <span class="gl-flex-shrink-0 gl-w-6 gl-mx-3">
+ <span class="gl-flex-shrink-0 gl-w-6 gl-display-flex">
<slot name="icon">
- <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2 item-icon" />
+ <gl-icon v-if="item.icon" :name="item.icon" class="gl-m-auto item-icon" />
</slot>
</span>
- <span class="gl-pr-3 gl-text-gray-900 gl-truncate-end">
+ <span class="gl-flex-grow-1 gl-text-gray-900 gl-truncate-end">
{{ item.title }}
</span>
- <span class="gl-flex-grow-1 gl-text-right gl-mr-3 gl-text-gray-400">
+ <span class="gl-text-right gl-text-gray-400">
<gl-icon :name="collapseIcon" />
</span>
</button>
diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue
index 0ee9db10ee2..c1e1f64dbc1 100644
--- a/app/assets/javascripts/super_sidebar/components/nav_item.vue
+++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue
@@ -102,9 +102,8 @@ export default {
},
computedLinkClasses() {
return {
- 'gl-py-2': this.isPinnable,
- 'gl-py-3': !this.isPinnable,
- 'gl-mx-2': this.isSubitem,
+ 'gl-px-2 gl-mx-2 gl-line-height-normal': this.isSubitem,
+ 'gl-px-3': !this.isSubitem,
[this.item.link_classes]: this.item.link_classes,
...this.linkClasses,
};
@@ -112,9 +111,6 @@ export default {
navItemLinkComponent() {
return this.item.to ? NavItemRouterLink : NavItemLink;
},
- iconClasses() {
- return this.isSubitem === true ? 'gl-ml-2 gl-mr-4' : 'gl-w-6 gl-mx-3';
- },
},
};
</script>
@@ -125,7 +121,7 @@ export default {
:is="navItemLinkComponent"
#default="{ isActive }"
v-bind="linkProps"
- class="nav-item-link gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-focus--focus"
+ class="nav-item-link gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-1 gl-py-2 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-focus--focus"
:class="computedLinkClasses"
data-qa-selector="nav_item_link"
data-testid="nav-item-link"
@@ -137,13 +133,13 @@ export default {
style="width: 3px; border-radius: 3px; margin-right: 1px"
data-testid="active-indicator"
></div>
- <div :class="iconClasses" class="gl-flex-shrink-0">
+ <div class="gl-flex-shrink-0 gl-w-6 gl-display-flex">
<slot name="icon">
- <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2 item-icon" />
+ <gl-icon v-if="item.icon" :name="item.icon" class="gl-m-auto item-icon" />
<gl-icon
v-else-if="isInPinnedSection"
name="grip"
- class="gl-text-gray-400 gl-ml-2 draggable-icon"
+ class="gl-m-auto gl-text-gray-400 draggable-icon"
/>
</slot>
</div>
@@ -154,7 +150,7 @@ export default {
</div>
</div>
<slot name="actions"></slot>
- <span v-if="hasPill || isPinnable" class="gl-text-right gl-mr-3 gl-relative">
+ <span v-if="hasPill || isPinnable" class="gl-text-right gl-relative">
<gl-badge
v-if="hasPill"
size="sm"
diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue
index 9d2836e9dfa..6058ed3a1cd 100644
--- a/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue
@@ -1,5 +1,6 @@
<script>
import { getCssClassDimensions } from '~/lib/utils/css_utils';
+import Tracking from '~/tracking';
import { SUPER_SIDEBAR_PEEK_OPEN_DELAY, SUPER_SIDEBAR_PEEK_CLOSE_DELAY } from '../constants';
export const STATE_CLOSED = 'closed';
@@ -9,6 +10,7 @@ export const STATE_WILL_CLOSE = 'will-close';
export default {
name: 'SidebarPeek',
+ mixins: [Tracking.mixin()],
created() {
// Nothing needs to observe these properties, so they are not reactive.
this.state = null;
@@ -88,6 +90,10 @@ export default {
open() {
if (this.changeState(STATE_OPEN)) {
this.clearTimers();
+ this.track('nav_peek', {
+ label: 'nav_hover',
+ property: 'nav_sidebar',
+ });
}
},
close() {
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
index 6b1efc4217c..c194401ce95 100644
--- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
@@ -3,6 +3,7 @@ import { GlButton } from '@gitlab/ui';
import { Mousetrap } from '~/lib/mousetrap';
import { keysFor, TOGGLE_SUPER_SIDEBAR } from '~/behaviors/shortcuts/keybindings';
import { __ } from '~/locale';
+import Tracking from '~/tracking';
import { sidebarState } from '../constants';
import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
import UserBar from './user_bar.vue';
@@ -26,6 +27,7 @@ export default {
TrialStatusPopover: () =>
import('ee_component/contextual_sidebar/components/trial_status_popover.vue'),
},
+ mixins: [Tracking.mixin()],
i18n: {
skipToMainContent: __('Skip to main content'),
},
@@ -68,6 +70,10 @@ export default {
},
methods: {
toggleSidebar() {
+ this.track(isCollapsed() ? 'nav_show' : 'nav_hide', {
+ label: 'nav_toggle_keyboard_shortcut',
+ property: 'nav_sidebar',
+ });
toggleSuperSidebarCollapsed(!isCollapsed(), true);
},
collapseSidebar() {
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
index 4fff5cf832e..87762a62c0f 100644
--- a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
+import Tracking from '~/tracking';
import { JS_TOGGLE_COLLAPSE_CLASS, JS_TOGGLE_EXPAND_CLASS, sidebarState } from '../constants';
import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
@@ -11,6 +12,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [Tracking.mixin()],
props: {
tooltipContainer: {
type: String,
@@ -52,6 +54,10 @@ export default {
},
methods: {
toggle() {
+ this.track(this.isCollapsed ? 'nav_show' : 'nav_hide', {
+ label: 'nav_toggle',
+ property: 'nav_sidebar',
+ });
toggleSuperSidebarCollapsed(!this.isCollapsed, true);
this.focusOtherToggle();
},
diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue
index d3b2143aaa7..a882df057fa 100644
--- a/app/assets/javascripts/super_sidebar/components/user_bar.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue
@@ -130,7 +130,6 @@ export default {
v-gl-tooltip.bottom.hover.html="searchTooltip"
v-gl-modal="$options.SEARCH_MODAL_ID"
data-testid="super-sidebar-search-button"
- data-qa-selector="global_search_button"
icon="search"
:aria-label="$options.i18n.search"
category="tertiary"
@@ -150,7 +149,6 @@ export default {
category="tertiary"
data-method="delete"
data-testid="stop-impersonation-btn"
- data-qa-selector="stop_impersonation_link"
/>
</div>
<div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2">
@@ -161,6 +159,7 @@ export default {
:count="userCounts.assigned_issues"
:href="sidebarData.issues_dashboard_path"
:label="$options.i18n.issues"
+ data-testid="issues-shortcut-button"
data-track-action="click_link"
data-track-label="issues_link"
data-track-property="nav_core_menu"
@@ -177,6 +176,7 @@ export default {
icon="merge-request-open"
:count="mergeRequestTotalCount"
:label="$options.i18n.mergeRequests"
+ data-testid="merge-requests-shortcut-button"
data-track-action="click_dropdown"
data-track-label="merge_requests_menu"
data-track-property="nav_core_menu"
@@ -189,7 +189,7 @@ export default {
:count="userCounts.todos"
:href="sidebarData.todos_dashboard_path"
:label="$options.i18n.todoList"
- data-qa-selector="todos_shortcut_button"
+ data-testid="todos-shortcut-button"
data-track-action="click_link"
data-track-label="todos_link"
data-track-property="nav_core_menu"
diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue
index 7d4991fbe96..869f07520a2 100644
--- a/app/assets/javascripts/super_sidebar/components/user_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue
@@ -11,11 +11,12 @@ import { s__, __, sprintf } from '~/locale';
import NewNavToggle from '~/nav/components/new_nav_toggle.vue';
import Tracking from '~/tracking';
import PersistentUserCallout from '~/persistent_user_callout';
-import { USER_MENU_TRACKING_DEFAULTS, DROPDOWN_Y_OFFSET } from '../constants';
+import { USER_MENU_TRACKING_DEFAULTS, DROPDOWN_Y_OFFSET, IMPERSONATING_OFFSET } from '../constants';
import UserNameGroup from './user_name_group.vue';
// Left offset required for the dropdown to be aligned with the super sidebar
-const DROPDOWN_X_OFFSET = -211;
+const DROPDOWN_X_OFFSET_BASE = -211;
+const DROPDOWN_X_OFFSET_IMPERSONATING = DROPDOWN_X_OFFSET_BASE + IMPERSONATING_OFFSET;
export default {
feedbackUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/409005',
@@ -47,7 +48,7 @@ export default {
SafeHtml,
},
mixins: [Tracking.mixin()],
- inject: ['toggleNewNavEndpoint'],
+ inject: ['toggleNewNavEndpoint', 'isImpersonating'],
props: {
data: {
required: true,
@@ -89,7 +90,7 @@ export default {
text: this.$options.i18n.editProfile,
href: this.data.settings.profile_path,
extraAttrs: {
- 'data-qa-selector': 'edit_profile_link',
+ 'data-testid': 'edit_profile_link',
...USER_MENU_TRACKING_DEFAULTS,
'data-track-label': 'user_edit_profile',
},
@@ -149,7 +150,7 @@ export default {
href: this.data.sign_out_link,
extraAttrs: {
'data-method': 'post',
- 'data-qa-selector': 'sign_out_link',
+ 'data-testid': 'sign_out_link',
class: 'sign-out-link',
},
},
@@ -188,6 +189,12 @@ export default {
showNotificationDot() {
return this.data.pipeline_minutes?.show_notification_dot;
},
+ dropdownOffset() {
+ return {
+ mainAxis: DROPDOWN_Y_OFFSET,
+ crossAxis: this.isImpersonating ? DROPDOWN_X_OFFSET_IMPERSONATING : DROPDOWN_X_OFFSET_BASE,
+ };
+ },
},
methods: {
onShow() {
@@ -221,7 +228,6 @@ export default {
});
},
},
- dropdownOffset: { mainAxis: DROPDOWN_Y_OFFSET, crossAxis: DROPDOWN_X_OFFSET },
};
</script>
@@ -229,9 +235,8 @@ export default {
<div>
<gl-disclosure-dropdown
ref="userDropdown"
- :dropdown-offset="$options.dropdownOffset"
+ :dropdown-offset="dropdownOffset"
data-testid="user-dropdown"
- data-qa-selector="user_menu"
:auto-close="false"
@shown="onShow"
>
@@ -243,7 +248,7 @@ export default {
:entity-name="data.name"
:src="data.avatar_url"
aria-hidden="true"
- data-qa-selector="user_avatar_content"
+ data-testid="user_avatar_content"
/>
<span
v-if="showNotificationDot"
diff --git a/app/assets/javascripts/super_sidebar/components/user_name_group.vue b/app/assets/javascripts/super_sidebar/components/user_name_group.vue
index dfaaaccf4a4..f3e8816cd37 100644
--- a/app/assets/javascripts/super_sidebar/components/user_name_group.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_name_group.vue
@@ -41,7 +41,7 @@ export default {
item.extraAttrs = {
...USER_MENU_TRACKING_DEFAULTS,
'data-track-label': 'user_profile',
- 'data-qa-selector': 'user_profile_link',
+ 'data-testid': 'user_profile_link',
};
}
@@ -74,13 +74,13 @@ export default {
class="gl-display-flex gl-align-items-center gl-mt-2 gl-font-sm"
>
<gl-emoji :data-name="user.status.emoji" class="gl-mr-1" />
- <span v-safe-html="user.status.message" class="gl-text-truncate"></span>
+ <span v-safe-html="user.status.message_html" class="gl-text-truncate"></span>
<gl-tooltip
:target="() => $refs.statusTooltipTarget"
boundary="viewport"
placement="bottom"
>
- <span v-safe-html="user.status.message"></span>
+ <span v-safe-html="user.status.message_html"></span>
</gl-tooltip>
</span>
</span>
diff --git a/app/assets/javascripts/super_sidebar/constants.js b/app/assets/javascripts/super_sidebar/constants.js
index 00ceaebe2cc..757bf9c7459 100644
--- a/app/assets/javascripts/super_sidebar/constants.js
+++ b/app/assets/javascripts/super_sidebar/constants.js
@@ -52,3 +52,5 @@ export const SIDEBAR_COOKIE_EXPIRATION = 365 * 10;
export const DROPDOWN_Y_OFFSET = 4;
export const NAV_ITEM_LINK_ACTIVE_CLASS = 'gl-bg-t-gray-a-08';
+
+export const IMPERSONATING_OFFSET = 32;
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
index f6afde02fa5..322eca72016 100644
--- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
@@ -65,13 +65,23 @@ export const initSuperSidebar = () => {
if (!el) return false;
- const { rootPath, sidebar, toggleNewNavEndpoint, forceDesktopExpandedSidebar } = el.dataset;
+ const {
+ rootPath,
+ sidebar,
+ toggleNewNavEndpoint,
+ forceDesktopExpandedSidebar,
+ commandPalette,
+ } = el.dataset;
bindSuperSidebarCollapsedEvents(forceDesktopExpandedSidebar);
initSuperSidebarCollapsedState(parseBoolean(forceDesktopExpandedSidebar));
const sidebarData = JSON.parse(sidebar);
const searchData = convertObjectPropsToCamelCase(sidebarData.search);
+
+ const commandPaletteData = JSON.parse(commandPalette);
+ const projectFilesPath = commandPaletteData.project_files_url;
+ const projectBlobPath = commandPaletteData.project_blob_url;
const commandPaletteCommands = sidebarData.create_new_menu_groups || [];
const commandPaletteLinks = convertObjectPropsToCamelCase(sidebarData.current_menu_items || []);
@@ -91,6 +101,8 @@ export const initSuperSidebar = () => {
commandPaletteLinks,
autocompletePath,
searchContext,
+ projectFilesPath,
+ projectBlobPath,
},
store: createStore({
searchPath,
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
index 2687ea5ccf8..feb7e274b07 100644
--- a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
@@ -1,6 +1,7 @@
import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
import { debounce } from 'lodash';
import { setCookie, getCookie } from '~/lib/utils/common_utils';
+import Tracking from '~/tracking';
import { sidebarState } from './constants';
export const SIDEBAR_COLLAPSED_CLASS = 'page-with-super-sidebar-collapsed';
@@ -50,7 +51,15 @@ export const bindSuperSidebarCollapsedEvents = (forceDesktopExpandedSidebar = fa
const widthChanged = previousWindowWidth !== newWindowWidth;
if (widthChanged) {
+ const collapsedBeforeResize = sidebarState.isCollapsed;
initSuperSidebarCollapsedState(forceDesktopExpandedSidebar);
+ const collapsedAfterResize = sidebarState.isCollapsed;
+ if (!collapsedBeforeResize && collapsedAfterResize) {
+ Tracking.event(undefined, 'nav_hide', {
+ label: 'browser_resize',
+ property: 'nav_sidebar',
+ });
+ }
}
previousWindowWidth = newWindowWidth;
}, 100);
diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.vue b/app/assets/javascripts/surveys/merge_request_experience/app.vue
index 333059b5340..2dd658d62ea 100644
--- a/app/assets/javascripts/surveys/merge_request_experience/app.vue
+++ b/app/assets/javascripts/surveys/merge_request_experience/app.vue
@@ -6,6 +6,7 @@ import { s__, __ } from '~/locale';
import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
import SatisfactionRate from '~/surveys/components/satisfaction_rate.vue';
import Tracking from '~/tracking';
+import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
const steps = [
{
@@ -50,6 +51,7 @@ export default {
thanks: s__('MrSurvey|Thank you for your feedback!'),
},
gitlabLogo,
+ privacyLink: `${PROMO_URL}/privacy/`,
data() {
return {
visible: false,
@@ -152,7 +154,7 @@ export default {
<template #link="{ content }">
<a
class="gl-text-decoration-underline gl-text-gray-500"
- href="https://about.gitlab.com/privacy/"
+ :href="$options.privacyLink"
target="_blank"
rel="noreferrer nofollow"
v-text="content"
diff --git a/app/assets/javascripts/token_access/components/outbound_token_access.vue b/app/assets/javascripts/token_access/components/outbound_token_access.vue
index 9c9b0d37b68..f70bb77b780 100644
--- a/app/assets/javascripts/token_access/components/outbound_token_access.vue
+++ b/app/assets/javascripts/token_access/components/outbound_token_access.vue
@@ -21,7 +21,6 @@ import getProjectsWithCIJobTokenScopeQuery from '../graphql/queries/get_projects
import TokenProjectsTable from './token_projects_table.vue';
// Note: This component will be removed in 17.0, as the outbound access token is getting deprecated
-// Some warnings are behind the `frozen_outbound_job_token_scopes` feature flag
export default {
i18n: {
toggleLabelTitle: s__('CICD|Limit CI_JOB_TOKEN access'),
@@ -127,14 +126,8 @@ export default {
ciJobTokenHelpPage() {
return helpPagePath('ci/jobs/ci_job_token#limit-your-projects-job-token-access');
},
- disableOutboundToken() {
- return (
- this.glFeatures?.frozenOutboundJobTokenScopes &&
- !this.glFeatures?.frozenOutboundJobTokenScopesOverride
- );
- },
disableTokenToggle() {
- return !this.jobTokenScopeEnabled && this.disableOutboundToken;
+ return !this.jobTokenScopeEnabled;
},
},
methods: {
@@ -226,7 +219,6 @@ export default {
<gl-loading-icon v-if="$apollo.loading" size="lg" class="gl-mt-5" />
<template v-else>
<gl-alert
- v-if="disableOutboundToken"
class="gl-mb-3"
variant="warning"
:dismissible="false"
@@ -260,7 +252,7 @@ export default {
<gl-link :href="ciJobTokenHelpPage" class="inline-link" target="_blank">
{{ content }}
</gl-link>
- <strong v-if="disableOutboundToken">{{ $options.i18n.disableToggleWarning }} </strong>
+ <strong>{{ $options.i18n.disableToggleWarning }} </strong>
</template>
</gl-sprintf>
</template>
@@ -274,7 +266,7 @@ export default {
<template #default>
<gl-form-input
v-model="targetProjectPath"
- :disabled="disableOutboundToken"
+ :disabled="true"
:placeholder="$options.i18n.addProjectPlaceholder"
data-testid="project-path-input"
/>
@@ -286,16 +278,6 @@ export default {
<gl-button @click="clearTargetProjectPath">{{ $options.i18n.cancel }}</gl-button>
</template>
</gl-card>
- <gl-alert
- v-if="!jobTokenScopeEnabled && !disableOutboundToken"
- class="gl-mb-3"
- variant="warning"
- :dismissible="false"
- :show-icon="false"
- data-testid="token-disabled-alert"
- >
- {{ $options.i18n.settingDisabledMessage }}
- </gl-alert>
<token-projects-table
:projects="projects"
:table-fields="$options.fields"
diff --git a/app/assets/javascripts/tracing/components/tracing_empty_state.vue b/app/assets/javascripts/tracing/components/tracing_empty_state.vue
new file mode 100644
index 00000000000..4cb3bd6d9f0
--- /dev/null
+++ b/app/assets/javascripts/tracing/components/tracing_empty_state.vue
@@ -0,0 +1,46 @@
+<script>
+import EMPTY_TRACING_SVG from '@gitlab/svgs/dist/illustrations/monitoring/tracing.svg?url';
+import { GlEmptyState, GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ EMPTY_TRACING_SVG,
+ name: 'TracingEmptyState',
+ i18n: {
+ title: __('Get started with Tracing'),
+ description: __('Monitor your applications with GitLab Distributed Tracing.'),
+ enableButtonText: __('Enable'),
+ },
+ components: {
+ GlEmptyState,
+ GlButton,
+ },
+ props: {
+ enableTracing: {
+ type: Function,
+ required: true,
+ },
+ },
+ methods: {
+ onEnabledClicked() {
+ this.enableTracing();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state :title="$options.i18n.title" :svg-path="$options.EMPTY_TRACING_SVG">
+ <template #description>
+ <div>
+ <span>{{ $options.i18n.description }}</span>
+ </div>
+ </template>
+
+ <template #actions>
+ <gl-button variant="confirm" class="gl-mx-2 gl-mb-3" @click="onEnabledClicked">
+ {{ $options.i18n.enableButtonText }}
+ </gl-button>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/tracing/components/tracing_list.vue b/app/assets/javascripts/tracing/components/tracing_list.vue
new file mode 100644
index 00000000000..294e520d7ac
--- /dev/null
+++ b/app/assets/javascripts/tracing/components/tracing_list.vue
@@ -0,0 +1,93 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { createAlert } from '~/alert';
+import TracingEmptyState from './tracing_empty_state.vue';
+import TracingTableList from './tracing_table_list.vue';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ TracingTableList,
+ TracingEmptyState,
+ },
+ props: {
+ observabilityClient: {
+ required: true,
+ type: Object,
+ },
+ },
+ data() {
+ return {
+ loading: true,
+ /**
+ * tracingEnabled: boolean | null.
+ * null identifies a state where we don't know if tracing is enabled or not (e.g. when fetching the status from the API fails)
+ */
+ tracingEnabled: null,
+ traces: [],
+ };
+ },
+ async created() {
+ this.checkEnabled();
+ },
+ methods: {
+ async checkEnabled() {
+ this.loading = true;
+ try {
+ this.tracingEnabled = await this.observabilityClient.isTracingEnabled();
+ if (this.tracingEnabled) {
+ await this.fetchTraces();
+ }
+ } catch (e) {
+ createAlert({
+ message: __('Failed to load page.'),
+ });
+ } finally {
+ this.loading = false;
+ }
+ },
+ async enableTracing() {
+ this.loading = true;
+ try {
+ await this.observabilityClient.enableTraces();
+ this.tracingEnabled = true;
+ await this.fetchTraces();
+ } catch (e) {
+ createAlert({
+ message: __('Failed to enable tracing.'),
+ });
+ } finally {
+ this.loading = false;
+ }
+ },
+ async fetchTraces() {
+ this.loading = true;
+ try {
+ const traces = await this.observabilityClient.fetchTraces();
+ this.traces = traces;
+ } catch (e) {
+ createAlert({
+ message: __('Failed to load traces.'),
+ });
+ } finally {
+ this.loading = false;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div v-if="loading" class="gl-py-5">
+ <gl-loading-icon size="lg" />
+ </div>
+
+ <template v-else-if="tracingEnabled !== null">
+ <tracing-empty-state v-if="tracingEnabled === false" :enable-tracing="enableTracing" />
+
+ <tracing-table-list v-else :traces="traces" @reload="fetchTraces" />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/tracing/components/tracing_table_list.vue b/app/assets/javascripts/tracing/components/tracing_table_list.vue
new file mode 100644
index 00000000000..7e8c296a7d4
--- /dev/null
+++ b/app/assets/javascripts/tracing/components/tracing_table_list.vue
@@ -0,0 +1,89 @@
+<script>
+import { GlTable, GlLink } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export const tableDataClass = 'gl-display-flex gl-md-display-table-cell gl-align-items-center';
+export default {
+ name: 'TracingTableList',
+ i18n: {
+ title: __('Traces'),
+ emptyText: __('No traces to display.'),
+ emptyLinkText: __('Check again'),
+ },
+ fields: [
+ {
+ key: 'date',
+ label: __('Date'),
+ tdClass: tableDataClass,
+ sortable: true,
+ },
+ {
+ key: 'service',
+ label: __('Service'),
+ tdClass: tableDataClass,
+ sortable: true,
+ },
+ {
+ key: 'operation',
+ label: __('Operation'),
+ tdClass: tableDataClass,
+ sortable: true,
+ },
+ {
+ key: 'duration',
+ label: __('Duration'),
+ thClass: 'gl-w-15p',
+ tdClass: tableDataClass,
+ sortable: true,
+ },
+ ],
+ components: {
+ GlTable,
+ GlLink,
+ },
+ props: {
+ traces: {
+ required: true,
+ type: Array,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h4 class="gl-display-block gl-md-display-none! gl-my-5">{{ $options.i18n.title }}</h4>
+
+ <gl-table
+ class="gl-mt-5"
+ :items="traces"
+ :fields="$options.fields"
+ show-empty
+ fixed
+ stacked="md"
+ tbody-tr-class="table-row"
+ >
+ <template #cell(date)="data">
+ {{ data.item.timestamp }}
+ </template>
+
+ <template #cell(service)="data">
+ {{ data.item.service_name }}
+ </template>
+
+ <template #cell(operation)="data">
+ {{ data.item.operation }}
+ </template>
+
+ <template #cell(duration)="data">
+ <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
+ {{ `${data.item.duration} ms` }}
+ </template>
+
+ <template #empty>
+ {{ $options.i18n.emptyText }}
+ <gl-link @click="$emit('reload')">{{ $options.i18n.emptyLinkText }}</gl-link>
+ </template>
+ </gl-table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/tracing/list_index.vue b/app/assets/javascripts/tracing/list_index.vue
new file mode 100644
index 00000000000..432fbb81506
--- /dev/null
+++ b/app/assets/javascripts/tracing/list_index.vue
@@ -0,0 +1,37 @@
+<script>
+import ObservabilityContainer from '~/observability/components/observability_container.vue';
+import TracingList from './components/tracing_list.vue';
+
+export default {
+ components: {
+ ObservabilityContainer,
+ TracingList,
+ },
+ props: {
+ oauthUrl: {
+ type: String,
+ required: true,
+ },
+ tracingUrl: {
+ type: String,
+ required: true,
+ },
+ provisioningUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <observability-container
+ :oauth-url="oauthUrl"
+ :tracing-url="tracingUrl"
+ :provisioning-url="provisioningUrl"
+ >
+ <template #default="{ observabilityClient }">
+ <tracing-list :observability-client="observabilityClient" />
+ </template>
+ </observability-container>
+</template>
diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js
index 968e866eedd..d0447fa167c 100644
--- a/app/assets/javascripts/tracking/constants.js
+++ b/app/assets/javascripts/tracking/constants.js
@@ -19,9 +19,14 @@ export const DEFAULT_SNOWPLOW_OPTIONS = {
export const ACTION_ATTR_SELECTOR = '[data-track-action]';
export const LOAD_ACTION_ATTR_SELECTOR = '[data-track-action="render"]';
+export const INTERNAL_EVENTS_SELECTOR = '[data-event-tracking]';
export const URLS_CACHE_STORAGE_KEY = 'gl-snowplow-pseudonymized-urls';
export const REFERRER_TTL = 24 * 60 * 60 * 1000;
export const GOOGLE_ANALYTICS_ID_COOKIE_NAME = '_ga';
+
+export const GITLAB_INTERNAL_EVENT_CATEGORY = 'InternalEventTracking';
+
+export const SERVICE_PING_SCHEMA = 'iglu:com.gitlab/gitlab_service_ping/jsonschema/1-0-0';
diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js
index 472ce3c5bbf..7c2cd6fde27 100644
--- a/app/assets/javascripts/tracking/index.js
+++ b/app/assets/javascripts/tracking/index.js
@@ -2,8 +2,10 @@ import { getAllExperimentContexts } from '~/experimentation/utils';
import { DEFAULT_SNOWPLOW_OPTIONS } from './constants';
import getStandardContext from './get_standard_context';
import Tracking from './tracking';
+import InternalEvents from './internal_events';
export { Tracking as default };
+export { InternalEvents };
/**
* Tracker initialization as defined in:
@@ -67,4 +69,6 @@ export function initDefaultTrackers() {
Tracking.bindDocument();
Tracking.trackLoadEvents();
+
+ InternalEvents.bindInternalEventDocument();
}
diff --git a/app/assets/javascripts/tracking/internal_events.js b/app/assets/javascripts/tracking/internal_events.js
new file mode 100644
index 00000000000..16cbb3e86e1
--- /dev/null
+++ b/app/assets/javascripts/tracking/internal_events.js
@@ -0,0 +1,58 @@
+import API from '~/api';
+
+import Tracking from './tracking';
+import { GITLAB_INTERNAL_EVENT_CATEGORY, SERVICE_PING_SCHEMA } from './constants';
+import { Tracker } from './tracker';
+import { InternalEventHandler } from './utils';
+
+const InternalEvents = {
+ /**
+ *
+ * @param {string} event
+ */
+ track_event(event) {
+ API.trackRedisHllUserEvent(event);
+ Tracking.event(GITLAB_INTERNAL_EVENT_CATEGORY, event, {
+ context: {
+ schema: SERVICE_PING_SCHEMA,
+ data: {
+ event_name: event,
+ data_source: 'redis_hll',
+ },
+ },
+ });
+ },
+ /**
+ * Returns an implementation of this class in the form of
+ * a Vue mixin.
+ */
+ mixin() {
+ return {
+ methods: {
+ track_event(event) {
+ InternalEvents.track_event(event);
+ },
+ },
+ };
+ },
+ /**
+ * Attaches event handlers for data-attributes powered events.
+ *
+ * @param {HTMLElement} parent - element containing data-attributes
+ * @returns {Object} handler - object containing name of the event and its corresponding function
+ */
+ bindInternalEventDocument(parent = document) {
+ if (!Tracker.enabled() || parent.internalEventsTrackingBound) {
+ return [];
+ }
+
+ // eslint-disable-next-line no-param-reassign
+ parent.internalEventsTrackingBound = true;
+
+ const handler = { name: 'click', func: (e) => InternalEventHandler(e, this.track_event) };
+ parent.addEventListener(handler.name, handler.func);
+ return handler;
+ },
+};
+
+export default InternalEvents;
diff --git a/app/assets/javascripts/tracking/utils.js b/app/assets/javascripts/tracking/utils.js
index cc0d7e7a44a..7cbc0f1843e 100644
--- a/app/assets/javascripts/tracking/utils.js
+++ b/app/assets/javascripts/tracking/utils.js
@@ -6,6 +6,7 @@ import {
LOAD_ACTION_ATTR_SELECTOR,
URLS_CACHE_STORAGE_KEY,
REFERRER_TTL,
+ INTERNAL_EVENTS_SELECTOR,
} from './constants';
export const addExperimentContext = (opts) => {
@@ -69,6 +70,23 @@ export const createEventPayload = (el, { suffix = '' } = {}) => {
};
};
+export const createInternalEventPayload = (el) => {
+ const { eventTracking } = el?.dataset || {};
+
+ return eventTracking;
+};
+
+export const InternalEventHandler = (e, func) => {
+ const el = e.target.closest(INTERNAL_EVENTS_SELECTOR);
+
+ if (!el) {
+ return;
+ }
+ const event = createInternalEventPayload(el);
+
+ func(event);
+};
+
export const eventHandler = (e, func, opts = {}) => {
const actionSelector = `${ACTION_ATTR_SELECTOR}:not(${LOAD_ACTION_ATTR_SELECTOR})`;
const el = e.target.closest(actionSelector);
diff --git a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
index cdaba2ad3f9..c1e513d3a00 100644
--- a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
+++ b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
@@ -20,7 +20,6 @@ export default {
const {
containerRegistrySize,
buildArtifactsSize,
- pipelineArtifactsSize,
lfsObjectsSize,
packagesSize,
repositorySize,
@@ -65,12 +64,6 @@ export default {
size: buildArtifactsSize,
},
{
- id: 'pipelineArtifacts',
- style: this.usageStyle(this.barRatio(pipelineArtifactsSize)),
- class: 'gl-bg-data-viz-green-800',
- size: pipelineArtifactsSize,
- },
- {
id: 'wiki',
style: this.usageStyle(this.barRatio(wikiSize)),
class: 'gl-bg-data-viz-magenta-500',
diff --git a/app/assets/javascripts/usage_quotas/storage/constants.js b/app/assets/javascripts/usage_quotas/storage/constants.js
index f08e8db26b9..8926e8c1e86 100644
--- a/app/assets/javascripts/usage_quotas/storage/constants.js
+++ b/app/assets/javascripts/usage_quotas/storage/constants.js
@@ -38,11 +38,6 @@ export const PROJECT_STORAGE_TYPES = [
description: s__('UsageQuota|Job artifacts created by CI/CD.'),
},
{
- id: 'pipelineArtifacts',
- name: __('Pipeline artifacts'),
- description: s__('UsageQuota|Pipeline artifacts created by CI/CD.'),
- },
- {
id: 'lfsObjects',
name: __('LFS'),
description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'),
@@ -70,19 +65,19 @@ export const PROJECT_STORAGE_TYPES = [
];
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',
}),
+ lfsObjects: helpPagePath('/user/project/repository/reducing_the_repo_size_using_git', {
+ anchor: 'repository-cleanup',
+ }),
+ containerRegistry: helpPagePath(
+ 'user/packages/container_registry/reduce_container_registry_storage',
+ ),
buildArtifacts: helpPagePath('ci/pipelines/job_artifacts', {
anchor: 'when-job-artifacts-are-deleted',
}),
- pipelineArtifacts: helpPagePath('/ci/pipelines/pipeline_artifacts', {
- anchor: 'when-pipeline-artifacts-are-deleted',
- }),
packages: helpPagePath('user/packages/package_registry/index.md', {
anchor: 'reduce-storage-usage',
}),
diff --git a/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql
index 85a181d3e01..a6de5ebae16 100644
--- a/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql
+++ b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql
@@ -12,7 +12,6 @@ query getProjectStorageStatistics($fullPath: ID!) {
statistics {
containerRegistrySize
buildArtifactsSize
- pipelineArtifactsSize
lfsObjectsSize
packagesSize
repositorySize
diff --git a/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue b/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue
new file mode 100644
index 00000000000..bf983d911ea
--- /dev/null
+++ b/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlDisclosureDropdown } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
+
+export default {
+ components: {
+ GlDisclosureDropdown,
+ },
+ props: {
+ userId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ // Only implement the copy function in MR for now
+ // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/122971
+ // The rest will be implemented in the upcoming MR.
+ dropdownItems: [
+ {
+ action: this.onUserIdCopy,
+ text: sprintf(this.$options.i18n.userId, { id: this.userId }),
+ extraAttrs: {
+ 'data-clipboard-text': this.userId,
+ },
+ },
+ ],
+ };
+ },
+ methods: {
+ onUserIdCopy() {
+ this.$toast.show(this.$options.i18n.userIdCopied);
+ },
+ },
+ i18n: {
+ userId: s__('UserProfile|Copy user ID: %{id}'),
+ userIdCopied: s__('UserProfile|User ID copied to clipboard'),
+ },
+};
+</script>
+
+<template>
+ <gl-disclosure-dropdown icon="ellipsis_v" category="tertiary" no-caret :items="dropdownItems" />
+</template>
diff --git a/app/assets/javascripts/users/profile/actions/index.js b/app/assets/javascripts/users/profile/actions/index.js
new file mode 100644
index 00000000000..37a3faf82a5
--- /dev/null
+++ b/app/assets/javascripts/users/profile/actions/index.js
@@ -0,0 +1,25 @@
+import Vue from 'vue';
+import { GlToast } from '@gitlab/ui';
+import UserActionsApp from './components/user_actions_app.vue';
+
+export const initUserActionsApp = () => {
+ const mountingEl = document.querySelector('.js-user-profile-actions');
+
+ if (!mountingEl) return false;
+
+ const { userId } = mountingEl.dataset;
+
+ Vue.use(GlToast);
+
+ return new Vue({
+ el: mountingEl,
+ name: 'UserActionsRoot',
+ render(createElement) {
+ return createElement(UserActionsApp, {
+ props: {
+ userId,
+ },
+ });
+ },
+ });
+};
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 028f5370028..f7c0f960c0e 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
@@ -68,7 +68,7 @@ export default {
},
isCollapsible() {
if (!this.isLoadingSummary && this.loadingState !== LOADING_STATES.collapsedError) {
- if (this.shouldCollapse) {
+ if ('shouldCollapse' in this) {
return this.shouldCollapse(this.collapsedData);
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
index 7e329399957..0b8f5ffa397 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import { markRaw } from '~/lib/utils/vue3compat/mark_raw';
import ExtensionBase from './base.vue';
// Holds all the currently registered extensions
@@ -7,45 +8,47 @@ export const registeredExtensions = Vue.observable({ extensions: [] });
export const registerExtension = (extension) => {
// Pushes into the extenions array a dynamically created Vue component
// that gets exteneded from `base.vue`
- registeredExtensions.extensions.push({
- extends: ExtensionBase,
- name: extension.name,
- props: {
- mr: {
- type: Object,
- required: true,
+ registeredExtensions.extensions.push(
+ markRaw({
+ extends: ExtensionBase,
+ name: extension.name,
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
},
- },
- telemetry: extension.telemetry,
- i18n: extension.i18n,
- expandEvent: extension.expandEvent,
- enablePolling: extension.enablePolling,
- enableExpandedPolling: extension.enableExpandedPolling,
- modalComponent: extension.modalComponent,
- computed: {
- ...extension.props.reduce(
- (acc, propKey) => ({
- ...acc,
- [propKey]() {
- return this.mr[propKey];
- },
- }),
- {},
- ),
- ...Object.keys(extension.computed).reduce(
- (acc, computedKey) => ({
- ...acc,
- // Making the computed property a method allows us to pass in arguments
- // this allows for each computed property to receive some data
- [computedKey]() {
- return extension.computed[computedKey];
- },
- }),
- {},
- ),
- },
- methods: {
- ...extension.methods,
- },
- });
+ telemetry: extension.telemetry,
+ i18n: extension.i18n,
+ expandEvent: extension.expandEvent,
+ enablePolling: extension.enablePolling,
+ enableExpandedPolling: extension.enableExpandedPolling,
+ modalComponent: extension.modalComponent,
+ computed: {
+ ...extension.props.reduce(
+ (acc, propKey) => ({
+ ...acc,
+ [propKey]() {
+ return this.mr[propKey];
+ },
+ }),
+ {},
+ ),
+ ...Object.keys(extension.computed).reduce(
+ (acc, computedKey) => ({
+ ...acc,
+ // Making the computed property a method allows us to pass in arguments
+ // this allows for each computed property to receive some data
+ [computedKey]() {
+ return extension.computed[computedKey];
+ },
+ }),
+ {},
+ ),
+ },
+ methods: {
+ ...extension.methods,
+ },
+ }),
+ );
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
index 5baeb309f79..8bf4d8816be 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
@@ -1,10 +1,9 @@
<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdown } from '@gitlab/ui';
export default {
components: {
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
},
props: {
commits: {
@@ -13,28 +12,36 @@ export default {
default: () => [],
},
},
+ computed: {
+ dropdownItems() {
+ return this.commits.map((commit) => ({
+ text: commit.title,
+ extraAttrs: {
+ text: commit.shortId || commit.short_Id,
+ },
+ action: () => {
+ this.$emit('input', commit.message);
+ },
+ }));
+ },
+ },
};
</script>
<template>
<div>
- <gl-dropdown
- right
- text="Use an existing commit message"
+ <gl-disclosure-dropdown
+ placement="right"
+ toggle-text="Use an existing commit message"
category="tertiary"
- variant="confirm"
+ :items="dropdownItems"
size="small"
class="mr-commit-dropdown"
>
- <gl-dropdown-item
- v-for="(commit, index) in commits"
- :key="index"
- class="text-nowrap text-truncate"
- @click="$emit('input', commit.message)"
- >
- <span class="monospace mr-2">{{ commit.shortId || commit.short_id }}</span>
- {{ commit.title }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <template #list-item="{ item }">
+ <span class="gl-mr-2">{{ item.extraAttrs.text }}</span>
+ {{ item.text }}
+ </template>
+ </gl-disclosure-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index 52cdafd4717..7071759b8bb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -27,7 +27,6 @@ import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/
import HelpPopover from '~/vue_shared/components/help_popover.vue';
import {
AUTO_MERGE_STRATEGIES,
- WARNING,
MT_MERGE_STRATEGY,
PIPELINE_FAILED_STATE,
STATE_MACHINE,
@@ -42,7 +41,6 @@ import CommitMessageDropdown from './commit_message_dropdown.vue';
import SquashBeforeMerge from './squash_before_merge.vue';
import MergeFailedPipelineConfirmationDialog from './merge_failed_pipeline_confirmation_dialog.vue';
-const PIPELINE_RUNNING_STATE = 'running';
const PIPELINE_PENDING_STATE = 'pending';
const PIPELINE_SUCCESS_STATE = 'success';
@@ -133,8 +131,6 @@ export default {
GlFormCheckbox,
GlSkeletonLoader,
MergeFailedPipelineConfirmationDialog,
- MergeTrainHelperIcon: () =>
- import('ee_component/vue_merge_request_widget/components/merge_train_helper_icon.vue'),
MergeImmediatelyConfirmationDialog: () =>
import(
'ee_component/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue'
@@ -176,14 +172,11 @@ export default {
};
},
computed: {
- stateData() {
- return this.state;
- },
hasCI() {
- return this.stateData.hasCI || this.stateData.hasCi;
+ return this.state.hasCI || this.state.hasCi;
},
isAutoMergeAvailable() {
- return !isEmpty(this.stateData.availableAutoMergeStrategies);
+ return !isEmpty(this.state.availableAutoMergeStrategies);
},
pipeline() {
return this.state.headPipeline;
@@ -246,30 +239,11 @@ export default {
return PIPELINE_SUCCESS_STATE;
},
- iconClass() {
- if (this.shouldRenderMergeTrainHelperIcon && !this.mr.preventMerge) {
- return PIPELINE_RUNNING_STATE;
- }
-
- if (
- this.status === PIPELINE_FAILED_STATE ||
- !this.commitMessage.length ||
- !this.isMergeAllowed ||
- this.mr.preventMerge
- ) {
- return WARNING;
- }
-
- return PIPELINE_SUCCESS_STATE;
- },
mergeButtonText() {
if (this.isMergingImmediately) {
return __('Merge in progress');
}
- if (this.isAutoMergeAvailable && !this.autoMergeLabelsEnabled) {
- return this.autoMergeTextLegacy;
- }
- if (this.isAutoMergeAvailable && this.autoMergeLabelsEnabled) {
+ if (this.isAutoMergeAvailable) {
return this.autoMergeText;
}
@@ -279,9 +253,6 @@ export default {
return __('Merge');
},
- autoMergeLabelsEnabled() {
- return window.gon?.features?.autoMergeLabelsMrWidget;
- },
showAutoMergeHelperText() {
return (
!(this.status === PIPELINE_FAILED_STATE || this.isPipelineFailed) &&
@@ -289,7 +260,7 @@ export default {
);
},
hasPipelineMustSucceedConflict() {
- return !this.hasCI && this.stateData.onlyAllowMergeIfPipelineSucceeds;
+ return !this.hasCI && this.state.onlyAllowMergeIfPipelineSucceeds;
},
isNotClosed() {
return this.mr.state !== STATUS_CLOSED;
@@ -322,12 +293,7 @@ export default {
return this.preferredAutoMergeStrategy === MT_MERGE_STRATEGY && this.isPipelineFailed;
},
shouldShowMergeControls() {
- return (
- (this.isMergeAllowed || this.isAutoMergeAvailable) &&
- (this.stateData.userPermissions?.canMerge || this.mr.canMerge) &&
- !this.mr.mergeOngoing &&
- !this.mr.autoMergeEnabled
- );
+ return this.state.userPermissions?.canMerge && this.mr.state === 'readyToMerge';
},
sourceBranchDeletedText() {
const isPreMerge = this.mr.state !== STATUS_MERGED;
@@ -354,6 +320,11 @@ export default {
};
},
},
+ watch: {
+ 'mr.state': function mrStateWatcher() {
+ this.isMakingRequest = false;
+ },
+ },
mounted() {
eventHub.$on('ApprovalUpdated', this.updateGraphqlState);
eventHub.$on('MRWidgetUpdateRequested', this.updateGraphqlState);
@@ -441,8 +412,6 @@ export default {
}
this.updateGraphqlState();
-
- this.isMakingRequest = false;
})
.catch(() => {
this.isMakingRequest = false;
@@ -511,13 +480,13 @@ export default {
},
i18n: {
mergeCommitTemplateHintText: s__(
- 'mrWidget|To change this default message, edit the template for merge commit messages. %{linkStart}Learn more.%{linkEnd}',
+ 'mrWidget|To change this default message, edit the template for merge commit messages. %{linkStart}Learn more%{linkEnd}.',
),
squashCommitTemplateHintText: s__(
- 'mrWidget|To change this default message, edit the template for squash commit messages. %{linkStart}Learn more.%{linkEnd}',
+ 'mrWidget|To change this default message, edit the template for squash commit messages. %{linkStart}Learn more%{linkEnd}.',
),
mergeAndSquashCommitTemplatesHintText: s__(
- 'mrWidget|To change these default messages, edit the templates for both the merge and squash commit messages. %{linkStart}Learn more.%{linkEnd}',
+ 'mrWidget|To change these default messages, edit the templates for both the merge and squash commit messages. %{linkStart}Learn more%{linkEnd}.',
),
sourceDivergedFromTargetText: s__('mrWidget|The source branch is %{link} the target branch'),
divergedCommits: (count) => n__('%d commit behind', '%d commits behind', count),
@@ -619,9 +588,8 @@ export default {
:href="commitTemplateHelpPage"
class="inline-link"
target="_blank"
+ >{{ content }}</gl-link
>
- {{ content }}
- </gl-link>
</template>
</gl-sprintf>
</p>
@@ -692,35 +660,21 @@ export default {
>
{{ __('Merge immediately') }}
</gl-dropdown-item>
- <merge-immediately-confirmation-dialog
- ref="confirmationDialog"
- :docs-url="mr.mergeImmediatelyDocsPath"
- @mergeImmediately="onMergeImmediatelyConfirmation"
- />
</gl-dropdown>
- <merge-train-failed-pipeline-confirmation-dialog
- :visible="isPipelineFailedModalVisibleMergeTrain"
- @startMergeTrain="onStartMergeTrainConfirmation"
- @cancel="isPipelineFailedModalVisibleMergeTrain = false"
- />
- <merge-failed-pipeline-confirmation-dialog
- :visible="isPipelineFailedModalVisibleNormalMerge"
- @mergeWithFailedPipeline="onMergeWithFailedPipelineConfirmation"
- @cancel="isPipelineFailedModalVisibleNormalMerge = false"
- />
</gl-button-group>
- <merge-train-helper-icon
- v-if="shouldRenderMergeTrainHelperIcon && !autoMergeLabelsEnabled"
- class="gl-mx-3"
- />
- <template v-if="showAutoMergeHelperText && autoMergeLabelsEnabled">
+ <template v-if="showAutoMergeHelperText">
<div
class="gl-ml-4 gl-text-gray-500 gl-font-sm"
data-qa-selector="auto_merge_helper_text"
+ data-testid="auto-merge-helper-text"
>
{{ autoMergeHelperText }}
</div>
- <help-popover class="gl-ml-2" :options="autoMergeHelpPopoverOptions">
+ <help-popover
+ class="gl-ml-2"
+ :options="autoMergeHelpPopoverOptions"
+ data-testid="auto-merge-helper-text-icon"
+ >
<gl-sprintf :message="autoMergePopoverSettings.bodyText">
<template #link="{ content }">
<gl-link
@@ -784,6 +738,21 @@ export default {
</div>
</div>
</div>
+ <merge-immediately-confirmation-dialog
+ ref="confirmationDialog"
+ :docs-url="mr.mergeImmediatelyDocsPath"
+ @mergeImmediately="onMergeImmediatelyConfirmation"
+ />
+ <merge-train-failed-pipeline-confirmation-dialog
+ :visible="isPipelineFailedModalVisibleMergeTrain"
+ @startMergeTrain="onStartMergeTrainConfirmation"
+ @cancel="isPipelineFailedModalVisibleMergeTrain = false"
+ />
+ <merge-failed-pipeline-confirmation-dialog
+ :visible="isPipelineFailedModalVisibleNormalMerge"
+ @mergeWithFailedPipeline="onMergeWithFailedPipelineConfirmation"
+ @cancel="isPipelineFailedModalVisibleNormalMerge = false"
+ />
</template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue
index 6655af92a55..c38c253564a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue
@@ -23,7 +23,7 @@ export default {
default: () => [],
},
},
- data: () => {
+ data() {
return {
timeout: null,
updatingTooltip: false,
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 334fc01c9f7..258fa4edcda 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
@@ -5,16 +5,24 @@ export default {
import(
'~/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue'
),
+
+ MrTerraformWidget: () => import('~/vue_merge_request_widget/extensions/terraform/index.vue'),
},
+
props: {
mr: {
type: Object,
required: true,
},
},
+
computed: {
+ terraformPlansWidget() {
+ return this.mr.terraformReportsPath && 'MrTerraformWidget';
+ },
+
widgets() {
- return ['MrSecurityWidget'];
+ return [this.terraformPlansWidget, 'MrSecurityWidget'].filter((w) => w);
},
},
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue
index cdce7c6625a..ec979861283 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue
@@ -30,6 +30,11 @@ export default {
required: false,
default: 2,
},
+ rowIndex: {
+ type: Number,
+ required: false,
+ default: -1,
+ },
},
computed: {
statusIcon() {
@@ -44,6 +49,9 @@ export default {
generatedSupportingText() {
return generateText(this.data.supportingText);
},
+ shouldShowThirdLevel() {
+ return this.data.children?.length > 0 && this.level === 2;
+ },
},
methods: {
onClickedAction(action) {
@@ -60,16 +68,19 @@ export default {
:widget-name="widgetName"
:header="data.header"
:help-popover="data.helpPopover"
+ :class="{ 'gl-border-top-0': rowIndex === 0 }"
>
<template #body>
- <div class="gl-display-flex gl-flex-direction-column">
- <div>
- <p v-safe-html="generatedText" class="gl-mb-0"></p>
- <gl-link v-if="data.link" :href="data.link.href">{{ data.link.text }}</gl-link>
- <p v-if="data.supportingText" v-safe-html="generatedSupportingText" class="gl-mb-0"></p>
- <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
- {{ data.badge.text }}
- </gl-badge>
+ <div class="gl-w-full gl-display-flex" :class="{ 'gl-flex-direction-column': level === 1 }">
+ <div class="gl-display-flex gl-flex-grow-1">
+ <div class="gl-display-flex gl-flex-grow-1 gl-flex-direction-column">
+ <p v-safe-html="generatedText" class="gl-mb-0 gl-mr-1"></p>
+ <gl-link v-if="data.link" :href="data.link.href">{{ data.link.text }}</gl-link>
+ <p v-if="data.supportingText" v-safe-html="generatedSupportingText" class="gl-mb-0"></p>
+ <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
+ {{ data.badge.text }}
+ </gl-badge>
+ </div>
<actions
:widget="widgetName"
:tertiary-buttons="data.actions"
@@ -78,10 +89,7 @@ export default {
/>
<p v-if="data.subtext" v-safe-html="generatedSubtext" class="gl-m-0 gl-font-sm"></p>
</div>
- <ul
- v-if="data.children && data.children.length > 0 && level === 2"
- class="gl-m-0 gl-p-0 gl-list-style-none"
- >
+ <ul v-if="shouldShowThirdLevel" class="gl-m-0 gl-p-0 gl-list-style-none">
<li v-for="(childData, index) in data.children" :key="childData.id || index">
<dynamic-content
:data="childData"
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 54eb15c8ac8..e327d848d8f 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
@@ -10,6 +10,7 @@ import HelpPopover from '~/vue_shared/components/help_popover.vue';
import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller';
import { EXTENSION_ICONS } from '../../constants';
import { createTelemetryHub } from '../extensions/telemetry';
+import { generateText } from '../extensions/utils';
import ContentRow from './widget_content_row.vue';
import DynamicContent from './dynamic_content.vue';
import StatusIcon from './status_icon.vue';
@@ -72,9 +73,12 @@ export default {
},
// If the summary slot is not used, this value will be used as a fallback.
summary: {
- type: String,
+ type: Object,
required: false,
default: undefined,
+ validator: (s) => {
+ return Boolean(s.title);
+ },
},
// If the content slot is not used, this value will be used as a fallback.
content: {
@@ -154,7 +158,7 @@ export default {
return {
isExpandedForTheFirstTime: true,
isCollapsed: true,
- isLoading: false,
+ isLoading: true,
isLoadingExpandedContent: false,
summaryError: null,
contentError: null,
@@ -162,6 +166,12 @@ export default {
};
},
computed: {
+ generatedSummary() {
+ return generateText(this.summary?.title || '');
+ },
+ generatedSubSummary() {
+ return generateText(this.summary?.subtitle || '');
+ },
collapseButtonLabel() {
return sprintf(this.isCollapsed ? __('Show details') : __('Hide details'));
},
@@ -171,6 +181,9 @@ export default {
hasActionButtons() {
return this.actionButtons.length > 0 || Boolean(this.$scopedSlots['action-buttons']);
},
+ contentWithKeyField() {
+ return this.content?.map((item, index) => ({ ...item, id: item.id || index }));
+ },
},
watch: {
hasError: {
@@ -289,7 +302,7 @@ export default {
<template>
<section class="media-section" data-testid="widget-extension">
- <div class="gl-px-5 gl-pr-4 gl-py-4 gl-align-items-center gl-display-flex">
+ <div class="gl-px-5 gl-pr-4 gl-py-4 gl-display-flex">
<status-icon
:level="1"
:name="widgetName"
@@ -302,7 +315,14 @@ export default {
>
<div class="gl-flex-grow-1" data-testid="widget-extension-top-level-summary">
<span v-if="summaryError">{{ summaryError }}</span>
- <slot v-else name="summary">{{ isLoading ? loadingText : summary }}</slot>
+ <slot v-else name="summary"
+ ><div v-safe-html="isLoading ? loadingText : generatedSummary"></div>
+ <div
+ v-if="!isLoading && generatedSubSummary"
+ v-safe-html="generatedSubSummary"
+ class="gl-font-sm gl-text-gray-700"
+ ></div
+ ></slot>
</div>
<div class="gl-display-flex">
<help-popover
@@ -336,7 +356,7 @@ export default {
</slot>
</div>
<div
- v-if="isCollapsible"
+ v-if="isCollapsible && !isLoading"
class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6"
>
<gl-button
@@ -376,8 +396,8 @@ export default {
<div v-else class="gl-w-full">
<slot name="content">
<dynamic-scroller
- v-if="content"
- :items="content"
+ v-if="contentWithKeyField"
+ :items="contentWithKeyField"
:min-item-size="32"
:style="{ maxHeight: '170px' }"
data-testid="dynamic-content-scroller"
@@ -390,6 +410,9 @@ export default {
:data="item"
:widget-name="widgetName"
:level="2"
+ :row-index="index"
+ data-testid="extension-list-item"
+ @clickedAction="onActionClick"
/>
</dynamic-scroller-item>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index db237bc7439..a59f48fb8b2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -1,5 +1,6 @@
import { s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
+import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility';
import { stateToComponentMap as classStateMap, stateKey } from './stores/state_maps';
export const FOUR_MINUTES_IN_MS = 1000 * 60 * 4;
@@ -26,7 +27,7 @@ export const SP_SHOW_TRACK_VALUE = 10;
export const SP_HELP_CONTENT = s__(
`mrWidget|GitLab %{linkStart}CI/CD can automatically build, test, and deploy your application.%{linkEnd} It only takes a few minutes to get started, and we can help you create a pipeline configuration file.`,
);
-export const SP_HELP_URL = 'https://docs.gitlab.com/ee/ci/quick_start/';
+export const SP_HELP_URL = `${DOCS_URL_IN_EE_DIR}/ci/quick_start/`;
export const SP_ICON_NAME = 'status_notfound';
export const MERGE_ACTIVE_STATUS_PHRASES = [
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
index 6155a912683..e7d8de97f20 100644
--- 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
@@ -73,6 +73,9 @@ export default {
hasSecurityReports() {
return this.artifacts.length > 0;
},
+ summary() {
+ return { title: this.$options.i18n.scansHaveRun };
+ },
},
methods: {
handleIsLoading(value) {
@@ -109,7 +112,7 @@ export default {
:widget-name="$options.name"
:is-collapsible="false"
:help-popover="$options.widgetHelpPopover"
- :summary="$options.i18n.scansHaveRun"
+ :summary="summary"
@is-loading="handleIsLoading"
>
<template #action-buttons>
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.vue
index c5cbed4a280..a6d12ed7aec 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.vue
@@ -1,12 +1,29 @@
+<script>
import { __, n__, s__, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
+import MrWidget from '~/vue_merge_request_widget/components/widget/widget.vue';
import { EXTENSION_ICONS } from '../../constants';
export default {
name: 'WidgetTerraform',
- enablePolling: true,
+ components: {
+ MrWidget,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ terraformData: {
+ collapsed: null,
+ expanded: null,
+ },
+ };
+ },
i18n: {
- label: s__('Terraform|Terraform reports'),
loading: s__('Terraform|Loading Terraform reports...'),
error: s__('Terraform|Failed to load Terraform reports'),
reportGenerated: s__('Terraform|A Terraform report was generated in your pipelines.'),
@@ -23,18 +40,13 @@ export default {
reportErrored: s__('Terraform|Generating the report caused an error.'),
fullLog: __('Full log'),
},
- props: ['terraformReportsPath'],
computed: {
- // Extension computed props
- statusIcon() {
- return EXTENSION_ICONS.warning;
+ terraformReportsPath() {
+ return this.mr.terraformReportsPath;
},
- },
- methods: {
- // Extension methods
- summary({ valid = [], invalid = [] }) {
- let title;
- let subtitle = '';
+
+ summary() {
+ const { valid = [], invalid = [] } = this.terraformData.collapsed || {};
const validText = sprintf(
n__(
@@ -60,20 +72,13 @@ export default {
false,
);
- if (valid.length) {
- title = validText;
- if (invalid.length) {
- subtitle = invalidText;
- }
- } else {
- title = invalidText;
- }
-
return {
- subject: title,
- meta: subtitle,
+ title: valid.length ? validText : invalidText,
+ subtitle: valid.length && invalid.length ? invalidText : undefined,
};
},
+ },
+ methods: {
fetchCollapsedData() {
return axios
.get(this.terraformReportsPath)
@@ -84,6 +89,10 @@ export default {
const formattedData = this.prepareReports(reports);
+ const { valid, invalid } = formattedData;
+ this.terraformData.collapsed = formattedData;
+ this.terraformData.expanded = [...valid, ...invalid];
+
return {
...res,
data: formattedData,
@@ -91,14 +100,10 @@ export default {
})
.catch(() => {
const formattedData = this.prepareReports([{ tf_report_error: 'api_error' }]);
-
+ this.terraformData.collapsed = formattedData;
return { data: formattedData };
});
},
- fetchFullData() {
- const { valid, invalid } = this.collapsedData;
- return Promise.resolve([...valid, ...invalid]);
- },
createReportRow(report, iconName) {
const addNum = Number(report.create);
const changeNum = Number(report.update);
@@ -176,4 +181,20 @@ export default {
return { valid, invalid };
},
},
+
+ WARNING_ICON: EXTENSION_ICONS.warning,
};
+</script>
+
+<template>
+ <mr-widget
+ :error-text="$options.i18n.error"
+ :status-icon-name="$options.WARNING_ICON"
+ :loading-text="$options.i18n.loading"
+ :widget-name="$options.name"
+ :is-collapsible="Boolean(terraformData.collapsed)"
+ :summary="summary"
+ :content="terraformData.expanded"
+ :fetch-collapsed-data="fetchCollapsedData"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
index 10a54c73273..2f49252a06b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -31,10 +31,6 @@ export default {
pipelineMustSucceedConflictText() {
return PIPELINE_MUST_SUCCEED_CONFLICT_TEXT;
},
- autoMergeTextLegacy() {
- // MWPS is currently the only auto merge strategy available in CE
- return __('Merge when pipeline succeeds');
- },
autoMergeText() {
return __('Set to auto-merge');
},
@@ -51,7 +47,7 @@ export default {
};
},
shouldShowMergeImmediatelyDropdown() {
- return this.isPipelineActive && !this.stateData.onlyAllowMergeIfPipelineSucceeds;
+ return this.isPipelineActive && !this.state.onlyAllowMergeIfPipelineSucceeds;
},
isMergeImmediatelyDangerous() {
return false;
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index af9e303594a..52a2f42f8ec 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -55,7 +55,6 @@ import eventHub from './event_hub';
import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables';
import getStateQuery from './queries/get_state.query.graphql';
import getStateSubscription from './queries/get_state.subscription.graphql';
-import terraformExtension from './extensions/terraform';
import accessibilityExtension from './extensions/accessibility';
import codeQualityExtension from './extensions/code_quality';
import testReportExtension from './extensions/test_report';
@@ -219,14 +218,6 @@ export default {
shouldRenderCodeQuality() {
return this.mr?.codequalityReportsPath;
},
- shouldRenderSourceBranchRemovalStatus() {
- return (
- !this.mr.canRemoveSourceBranch &&
- this.mr.shouldRemoveSourceBranch &&
- !this.mr.isNothingToMergeState &&
- !this.mr.isMergedState
- );
- },
shouldRenderCollaborationStatus() {
return this.mr.allowCollaboration && this.mr.isOpen;
},
@@ -238,9 +229,6 @@ export default {
this.mr.mergePipelinesEnabled && this.mr.sourceProjectId !== this.mr.targetProjectId,
);
},
- shouldRenderTerraformPlans() {
- return Boolean(this.mr?.terraformReportsPath);
- },
shouldRenderTestReport() {
return Boolean(this.mr?.testResultsPath);
},
@@ -292,11 +280,6 @@ export default {
this.initPostMergeDeploymentsPolling();
}
},
- shouldRenderTerraformPlans(newVal) {
- if (newVal) {
- this.registerTerraformPlans();
- }
- },
shouldRenderCodeQuality(newVal) {
if (newVal) {
this.registerCodeQualityExtension();
@@ -546,11 +529,6 @@ export default {
dismissSuggestPipelines() {
this.mr.isDismissedSuggestPipeline = true;
},
- registerTerraformPlans() {
- if (this.shouldRenderTerraformPlans) {
- registerExtension(terraformExtension);
- }
- },
registerAccessibilityExtension() {
if (this.shouldShowAccessibilityReport) {
registerExtension(accessibilityExtension);
@@ -570,7 +548,7 @@ export default {
};
</script>
<template>
- <div v-if="!loading" class="mr-state-widget gl-mt-3">
+ <div v-if="!loading" id="widget-state" class="mr-state-widget gl-mt-5">
<header
v-if="shouldRenderCollaborationStatus"
class="gl-rounded-base gl-border-solid gl-border-1 gl-border-gray-100 gl-overflow-hidden mr-widget-workflow gl-mt-0!"
@@ -590,7 +568,11 @@ export default {
:user-callout-feature-id="mr.suggestPipelineFeatureId"
@dismiss="dismissSuggestPipelines"
/>
- <mr-widget-pipeline-container v-if="shouldRenderPipelines" :mr="mr" />
+ <mr-widget-pipeline-container
+ v-if="shouldRenderPipelines"
+ :mr="mr"
+ data-testid="pipeline-container"
+ />
<mr-widget-approvals v-if="shouldRenderApprovals" :mr="mr" :service="service" />
<report-widget-container>
<extensions-container v-if="hasExtensions" :mr="mr" />
@@ -637,6 +619,7 @@ export default {
class="js-post-merge-pipeline mr-widget-workflow"
:mr="mr"
:is-post-merge="true"
+ data-testid="merged-pipeline-container"
/>
</div>
<loading v-else />
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue
index 4ec301b946b..2d3815439a6 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue
@@ -22,15 +22,15 @@ const DATA_REFETCH_DELAY = 250;
export default {
i18n: {
FETCH_USERS_ERROR: s__(
- 'AlertManagement|There was an error while updating the assignee(s) list. Please try again.',
+ 'AlertManagement|There was an error while updating the assignees list. Please try again.',
),
UPDATE_ALERT_ASSIGNEES_ERROR: s__(
- 'AlertManagement|There was an error while updating the assignee(s) of the alert. Please try again.',
+ 'AlertManagement|There was an error while updating the assignees of the alert. Please try again.',
),
UPDATE_ALERT_ASSIGNEES_GRAPHQL_ERROR: s__(
'AlertManagement|This assignee cannot be assigned to this alert.',
),
- ASSIGNEES_BLOCK: s__('AlertManagement|Alert assignee(s): %{assignees}'),
+ ASSIGNEES_BLOCK: s__('AlertManagement|Alert assignees: %{assignees}'),
},
components: {
GlIcon,
diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue
index c3f3226c46e..1d6dbef799a 100644
--- a/app/assets/javascripts/vue_shared/components/actions_button.vue
+++ b/app/assets/javascripts/vue_shared/components/actions_button.vue
@@ -45,8 +45,12 @@ export default {
:category="category"
:toggle-text="toggleText"
data-qa-selector="action_dropdown"
+ fluid-width
+ block
+ @shown="$emit('shown')"
+ @hidden="$emit('hidden')"
>
- <gl-disclosure-dropdown-group>
+ <gl-disclosure-dropdown-group class="edit-dropdown-group-width">
<gl-disclosure-dropdown-item
v-for="action in actions"
:key="action.key"
@@ -65,5 +69,6 @@ export default {
</template>
</gl-disclosure-dropdown-item>
</gl-disclosure-dropdown-group>
+ <slot></slot>
</gl-disclosure-dropdown>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index 8f1f7ba0ad8..59f03b41144 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -184,6 +184,7 @@ export default {
class="gl-mr-3 gl-my-2"
:class="awardList.classes"
:title="awardList.title"
+ :data-emoji-name="awardList.name"
data-testid="award-button"
@click="handleAward(awardList.name)"
>
@@ -209,7 +210,6 @@ export default {
@hidden="setIsMenuOpen(false)"
>
<template #button-content>
- <span class="gl-sr-only">{{ __('Add reaction') }}</span>
<span class="reaction-control-icon reaction-control-icon-neutral">
<gl-icon name="slight-smile" />
</span>
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
index 3a3929fba9b..3e24a35ea39 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
@@ -12,8 +12,22 @@ export default {
SafeHtml,
},
mixins: [ViewerMixin],
+ data() {
+ return {
+ isLoading: true,
+ };
+ },
mounted() {
- handleBlobRichViewer(this.$refs.content, this.type);
+ window.requestIdleCallback(async () => {
+ /**
+ * Rendering Markdown usually takes long due to the amount of HTML being parsed.
+ * This ensures that content is loaded only when the browser goes into idle.
+ * More details here: https://gitlab.com/gitlab-org/gitlab/-/issues/331448
+ * */
+ this.isLoading = false;
+ await this.$nextTick();
+ handleBlobRichViewer(this.$refs.content, this.type);
+ });
},
safeHtmlConfig: {
ADD_TAGS: ['gl-emoji', 'copy-code'],
@@ -22,6 +36,7 @@ export default {
</script>
<template>
<markdown-field-view
+ v-if="!isLoading"
ref="content"
v-safe-html:[$options.safeHtmlConfig]="richViewer || content"
/>
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index 0d7547d88a1..6670b931416 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -36,6 +36,15 @@ export default {
status: {
type: Object,
required: true,
+ validator(status) {
+ const { group, icon } = status;
+ return (
+ typeof group === 'string' &&
+ group.length &&
+ typeof icon === 'string' &&
+ icon.startsWith('status_')
+ );
+ },
},
size: {
type: Number,
@@ -69,7 +78,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 gl-line-height-0`;
+ return `ci-status-icon 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;
@@ -84,7 +93,6 @@ export default {
{ interactive: isInteractive, active: isActive, borderless: isBorderless },
]"
:style="{ height: `${size}px`, width: `${size}px` }"
- data-testid="ci-icon-wrapper"
>
<gl-icon :name="icon" :size="size" :class="cssClasses" :aria-label="status.icon" />
</span>
diff --git a/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue b/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue
index 352d03befc3..d98858da95f 100644
--- a/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue
+++ b/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue
@@ -1,6 +1,6 @@
<script>
+import { escape } from 'lodash';
import SafeHtml from '~/vue_shared/directives/safe_html';
-
import languageLoader from '~/content_editor/services/highlight_js_language_loader';
import CodeBlock from './code_block.vue';
@@ -39,7 +39,7 @@ export default {
return this.hljs.highlight(this.code, { language: this.language }).value;
}
- return this.code;
+ return escape(this.code);
},
},
async mounted() {
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js b/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js
index 90d55d0f93f..c6b9e61b85f 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js
@@ -6,7 +6,5 @@ export const CONFIRM_DANGER_MODAL_BUTTON = __('Confirm');
export const CONFIRM_DANGER_WARNING = __(
'This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention.',
);
-export const CONFIRM_DANGER_PHRASE_TEXT = __(
- 'Please type %{phrase_code} to proceed or close this modal to cancel.',
-);
+export const CONFIRM_DANGER_PHRASE_TEXT = __('Please type %{phrase_code} to proceed.');
export const CONFIRM_DANGER_MODAL_CANCEL = __('Cancel');
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue
index 1a3220d8db9..970c24c6e87 100644
--- a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue
+++ b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue
@@ -75,10 +75,13 @@ export default {
computed: {
selected: {
set(value) {
- this.$emit('input', value);
this.selectedValue = value;
this.selectedText =
value === null ? null : this.items.find((item) => item.value === value).text;
+ this.$emit('input', {
+ value: this.selectedValue,
+ text: this.selectedText,
+ });
},
get() {
return this.selectedValue;
@@ -161,7 +164,7 @@ export default {
},
onReset() {
this.selected = null;
- this.$emit('input', null);
+ this.$emit('input', {});
},
onBottomReached() {
this.fetchEntities(this.page + 1);
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue
index ff137d764ee..71e3bf4ff63 100644
--- a/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue
+++ b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue
@@ -121,6 +121,7 @@ export default {
:default-toggle-text="$options.i18n.toggleText"
:fetch-items="fetchGroups"
:fetch-initial-selection-text="fetchGroupName"
+ v-on="$listeners"
>
<template #error>
<gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue
index 7af3819f2a5..13a825a68f6 100644
--- a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue
+++ b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue
@@ -166,6 +166,7 @@ export default {
:fetch-initial-selection-text="fetchProjectName"
:block="block"
clearable
+ v-on="$listeners"
>
<template v-if="hasHtmlLabel" #label>
<span v-safe-html="label"></span>
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 28baabbdb81..1adda905006 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -114,11 +114,7 @@ export default {
</script>
<template>
- <header
- class="page-content-header gl-md-display-flex gl-min-h-7"
- data-qa-selector="pipeline_header"
- data-testid="ci-header-content"
- >
+ <header class="page-content-header gl-md-display-flex gl-min-h-7" data-testid="ci-header-content">
<section class="header-main-content gl-mr-3">
<ci-badge-link class="gl-mr-3" :status="status" />
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
index ad89b78b521..b447822b1e0 100644
--- 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
@@ -1,5 +1,6 @@
import Vue from 'vue';
import ListboxInput from '~/vue_shared/components/listbox_input/listbox_input.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
export const initListboxInputs = () => {
const els = [...document.querySelectorAll('.js-listbox-input')];
@@ -30,6 +31,8 @@ export const initListboxInputs = () => {
name,
defaultToggleText,
selected: this.selected,
+ block: parseBoolean(el.dataset.block),
+ fluidWidth: parseBoolean(el.dataset.fluidWidth),
items,
},
attrs: {
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 0f8ff5291a4..a59a7494472 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
@@ -47,6 +47,21 @@ export default {
required: false,
default: false,
},
+ fluidWidth: {
+ type: GlCollapsibleListbox.props.fluidWidth.type,
+ required: false,
+ default: GlCollapsibleListbox.props.fluidWidth.default,
+ },
+ placement: {
+ type: GlCollapsibleListbox.props.placement.type,
+ required: false,
+ default: GlCollapsibleListbox.props.placement.default,
+ },
+ block: {
+ type: GlCollapsibleListbox.props.block.type,
+ required: false,
+ default: GlCollapsibleListbox.props.block.default,
+ },
},
data() {
return {
@@ -123,6 +138,9 @@ export default {
:searchable="isSearchable"
:no-results-text="$options.i18n.noResultsText"
:disabled="disabled"
+ :fluid-width="fluidWidth"
+ :placement="placement"
+ :block="block"
@search="search"
@select="$emit($options.model.event, $event)"
/>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
index f51ec715678..a570abae9d3 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
@@ -12,7 +12,8 @@ export default {
},
defaultCommitMessage: {
type: String,
- required: true,
+ required: false,
+ default: null,
},
batchSuggestionsCount: {
type: Number,
diff --git a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue
index 186f5619b87..966a5556d24 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue
@@ -1,7 +1,6 @@
<script>
import { GlCollapsibleListbox, GlTooltip, GlButton } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
-import { updateText } from '~/lib/utils/text_markdown';
import savedRepliesQuery from './saved_replies.query.graphql';
export default {
@@ -54,20 +53,8 @@ export default {
},
onSelect(id) {
const savedReply = this.savedReplies.find((r) => r.id === id);
- const textArea = this.$el.closest('.md-area')?.querySelector('textarea');
-
- if (savedReply && textArea) {
- updateText({
- textArea,
- tag: savedReply.content,
- cursorOffset: 0,
- wrap: false,
- });
-
- // Wait for text to be added into textarea
- requestAnimationFrame(() => {
- textArea.focus();
- });
+ if (savedReply) {
+ this.$emit('select', savedReply.content);
}
},
},
@@ -81,13 +68,14 @@ export default {
:items="filteredSavedReplies"
:toggle-text="__('Insert comment template')"
text-sr-only
+ no-caret
toggle-class="js-comment-template-toggle"
icon="comment-lines"
category="tertiary"
placement="right"
searchable
size="small"
- class="comment-template-dropdown"
+ class="comment-template-dropdown gl-mr-3"
positioning-strategy="fixed"
:searching="$apollo.queries.savedReplies.loading"
@shown="fetchCommentTemplates"
@@ -104,7 +92,7 @@ export default {
</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"
+ class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-200 gl-display-flex gl-justify-content-center gl-p-2"
>
<gl-button
:href="newCommentTemplatePath"
@@ -130,4 +118,8 @@ export default {
.comment-template-dropdown .gl-new-dropdown-item-check-icon {
display: none;
}
+
+.comment-template-dropdown input {
+ border-radius: 0;
+}
</style>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue
index 645975ca565..2426a917a53 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue
@@ -1,10 +1,16 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlPopover, GlLink } from '@gitlab/ui';
+import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
import { __ } from '~/locale';
+import RICH_TEXT_EDITOR_ILLUSTRATION from '../../../../images/callouts/rich_text_editor_illustration.svg?url';
+import { counter } from './utils';
export default {
components: {
GlButton,
+ GlLink,
+ GlPopover,
+ UserCalloutDismisser,
},
props: {
value: {
@@ -12,21 +18,102 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ counter: counter(),
+ };
+ },
computed: {
+ showPromoPopover() {
+ return this.markdownEditorSelected && this.counter === 0;
+ },
markdownEditorSelected() {
return this.value === 'markdown';
},
text() {
- return this.markdownEditorSelected ? __('Switch to rich text') : __('Switch to Markdown');
+ return this.markdownEditorSelected
+ ? __('Switch to rich text editing')
+ : __('Switch to plain text editing');
},
},
+ methods: {
+ switchEditorType(insertTemplate = false) {
+ this.$emit('switch', insertTemplate);
+ },
+ },
+ richTextEditorButtonId: 'switch-to-rich-text-editor',
+ RICH_TEXT_EDITOR_ILLUSTRATION,
};
</script>
<template>
- <gl-button
- class="btn btn-default btn-sm gl-button btn-default-tertiary"
- data-qa-selector="editing_mode_switcher"
- @click="$emit('input')"
- >{{ text }}</gl-button
- >
+ <div class="content-editor-switcher gl-display-inline-flex gl-align-items-center">
+ <user-callout-dismisser feature-name="rich_text_editor">
+ <template #default="{ dismiss, shouldShowCallout }">
+ <div>
+ <gl-popover
+ :target="$options.richTextEditorButtonId"
+ :show="Boolean(showPromoPopover && shouldShowCallout)"
+ show-close-button
+ :css-classes="['rich-text-promo-popover gl-p-2']"
+ triggers="manual"
+ data-testid="rich-text-promo-popover"
+ @close-button-clicked="dismiss"
+ >
+ <img
+ :src="$options.RICH_TEXT_EDITOR_ILLUSTRATION"
+ :alt="''"
+ class="rich-text-promo-popover-illustration"
+ width="280"
+ height="130"
+ />
+ <h5 class="gl-mt-3 gl-mb-3">{{ __('Writing just got easier') }}</h5>
+ <p class="gl-m-0">
+ {{
+ __(
+ 'Use the new rich text editor to see your text and tables fully formatted as you type. No need to remember any formatting syntax, or switch between preview and editing modes!',
+ )
+ }}
+ </p>
+ <gl-link
+ class="gl-button btn btn-confirm block gl-mb-2 gl-mt-4"
+ variant="confirm"
+ category="primary"
+ target="_blank"
+ block
+ @click="
+ switchEditorType(showPromoPopover);
+ dismiss();
+ "
+ >
+ {{ __('Try the rich text editor now') }}
+ </gl-link>
+ </gl-popover>
+ <gl-button
+ :id="$options.richTextEditorButtonId"
+ class="btn btn-default btn-sm gl-button btn-default-tertiary gl-font-sm! gl-text-secondary! gl-px-4!"
+ data-qa-selector="editing_mode_switcher"
+ @click="
+ switchEditorType();
+ dismiss();
+ "
+ >{{ text }}</gl-button
+ >
+ </div>
+ </template>
+ </user-callout-dismisser>
+ </div>
</template>
+<style>
+.rich-text-promo-popover {
+ box-shadow: 0 0 18px -1.9px rgba(119, 89, 194, 0.16), 0 0 12.9px -1.7px rgba(119, 89, 194, 0.16),
+ 0 0 9.2px -1.4px rgba(119, 89, 194, 0.16), 0 0 6.4px -1.1px rgba(119, 89, 194, 0.16),
+ 0 0 4.5px -0.8px rgba(119, 89, 194, 0.16), 0 0 3px -0.6px rgba(119, 89, 194, 0.16),
+ 0 0 1.8px -0.3px rgba(119, 89, 194, 0.16), 0 0 0.6px rgba(119, 89, 194, 0.16);
+ z-index: 999;
+}
+
+.rich-text-promo-popover-illustration {
+ width: calc(100% + 32px);
+ margin: -32px -16px 0;
+}
+</style>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 602a83132e4..7c569763a75 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -68,10 +68,10 @@ export default {
required: false,
default: false,
},
- quickActionsDocsPath: {
- type: String,
+ supportsQuickActions: {
+ type: Boolean,
required: false,
- default: '',
+ default: false,
},
canAttachFile: {
type: Boolean,
@@ -355,10 +355,7 @@ export default {
<template>
<div
ref="gl-form"
- :class="{
- 'gl-border-none! gl-shadow-none!': removeBorder,
- }"
- class="js-vue-markdown-field md-area position-relative gfm-form"
+ class="js-vue-markdown-field md-area position-relative gfm-form gl-overflow-hidden"
:data-uploads-path="uploadsPath"
>
<markdown-header
@@ -371,13 +368,12 @@ export default {
:uploads-path="uploadsPath"
:markdown-preview-path="markdownPreviewPath"
:drawio-enabled="drawioEnabled"
+ :supports-quick-actions="supportsQuickActions"
data-testid="markdownHeader"
:restricted-tool-bar-items="restrictedToolBarItems"
- :show-content-editor-switcher="showContentEditorSwitcher"
@showPreview="showPreview"
@hidePreview="hidePreview"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
- @enableContentEditor="$emit('enableContentEditor')"
/>
<div v-show="!previewMarkdown" class="md-write-holder">
<div class="zen-backdrop">
@@ -391,36 +387,31 @@ export default {
</a>
<markdown-toolbar
:markdown-docs-path="markdownDocsPath"
- :quick-actions-docs-path="quickActionsDocsPath"
:can-attach-file="canAttachFile"
:show-comment-tool-bar="showCommentToolBar"
+ :show-content-editor-switcher="showContentEditorSwitcher"
+ @enableContentEditor="$emit('enableContentEditor')"
/>
</div>
</div>
- <template v-if="hasSuggestion">
- <div
- v-show="previewMarkdown"
- ref="markdown-preview"
- class="js-vue-md-preview md-preview-holder gl-px-5"
- >
- <suggestions
- v-if="hasSuggestion"
- :note-html="markdownPreview"
- :line-type="lineType"
- :disabled="true"
- :suggestions="suggestions"
- :help-page-path="helpPagePath"
- />
- </div>
- </template>
- <template v-else>
- <div
- v-show="previewMarkdown"
- ref="markdown-preview"
- v-safe-html:[$options.safeHtmlConfig]="markdownPreview"
- class="js-vue-md-preview md md-preview-holder gl-px-5"
- ></div>
- </template>
+ <div
+ v-show="previewMarkdown"
+ ref="markdown-preview"
+ class="js-vue-md-preview md-preview-holder gl-px-5"
+ :class="{ md: !hasSuggestion }"
+ >
+ <suggestions
+ v-if="hasSuggestion"
+ :note-html="markdownPreview"
+ :line-type="lineType"
+ :disabled="true"
+ :suggestions="suggestions"
+ :help-page-path="helpPagePath"
+ />
+ <template v-else>
+ <div v-safe-html:[$options.safeHtmlConfig]="markdownPreview"></div>
+ </template>
+ </div>
<div
v-if="referencedCommands && previewMarkdown && !markdownPreviewLoading"
v-safe-html:[$options.safeHtmlConfig]="referencedCommands"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index af0b34f1389..0907e064e01 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -13,13 +13,13 @@ import {
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getModifierKey } from '~/constants';
import { getSelectedFragment } from '~/lib/utils/common_utils';
-import { s__, __ } from '~/locale';
+import { truncateSha } from '~/lib/utils/text_utility';
+import { s__, __, sprintf } from '~/locale';
import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
import { updateText } from '~/lib/utils/text_markdown';
import ToolbarButton from './toolbar_button.vue';
import DrawioToolbarButton from './drawio_toolbar_button.vue';
import CommentTemplatesDropdown from './comment_templates_dropdown.vue';
-import EditorModeSwitcher from './editor_mode_switcher.vue';
export default {
components: {
@@ -29,7 +29,6 @@ export default {
DrawioToolbarButton,
CommentTemplatesDropdown,
AiActionsDropdown: () => import('ee_component/ai/components/ai_actions_dropdown.vue'),
- EditorModeSwitcher,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -40,6 +39,7 @@ export default {
default: null,
},
editorAiActions: { default: () => [] },
+ mrGeneratedContent: { default: null },
},
props: {
previewMarkdown: {
@@ -91,17 +91,19 @@ export default {
required: false,
default: false,
},
- showContentEditorSwitcher: {
+ supportsQuickActions: {
type: Boolean,
required: false,
default: false,
},
},
data() {
+ const modifierKey = getModifierKey();
return {
tag: '> ',
suggestPopoverVisible: false,
- modifierKey: getModifierKey(),
+ modifierKey,
+ shiftKey: modifierKey === '⌘' ? '⇧' : 'Shift+',
};
},
computed: {
@@ -126,9 +128,6 @@ export default {
const expandText = s__('MarkdownEditor|Click to expand');
return [`<details><summary>${expandText}</summary>`, `{text}`, '</details>'].join('\n');
},
- showEditorModeSwitcher() {
- return this.showContentEditorSwitcher && !this.previewMarkdown;
- },
},
watch: {
showSuggestPopover() {
@@ -199,17 +198,25 @@ export default {
insertIntoTextarea(text) {
const textArea = this.$el.closest('.md-area')?.querySelector('textarea');
if (textArea) {
- const generatedByText = `${text}\n\n---\n\n_${__('This comment was generated by AI')}_`;
updateText({
textArea,
- tag: generatedByText,
+ tag: text,
cursorOffset: 0,
wrap: false,
});
}
},
- handleEditorModeChanged() {
- this.$emit('enableContentEditor');
+ replaceTextarea(text) {
+ const { description, descriptionForSha } = this.$options.i18n;
+ const headSha = document.getElementById('merge_request_diff_head_sha').value;
+ const addendum = headSha
+ ? sprintf(descriptionForSha, { revision: truncateSha(headSha) })
+ : description;
+
+ if (this.mrGeneratedContent) {
+ this.mrGeneratedContent.setGeneratedContent(`${text}\n\n---\n\n_${addendum}_`);
+ this.mrGeneratedContent.showWarning();
+ }
},
switchPreview() {
if (this.previewMarkdown) {
@@ -218,6 +225,12 @@ export default {
this.showMarkdownPreview();
}
},
+ insertAIAction(text) {
+ this.insertIntoTextarea(`${text}\n\n---\n\n_${__('This comment was generated by AI')}_`);
+ },
+ insertSavedReply(savedReply) {
+ this.insertIntoTextarea(savedReply);
+ },
},
shortcuts: {
bold: keysFor(BOLD_TEXT),
@@ -228,27 +241,36 @@ export default {
outdent: keysFor(OUTDENT_LINE),
},
i18n: {
- preview: __('Preview'),
+ comment: __('This comment was generated by AI'),
+ description: s__('MergeRequest|This description was generated using AI'),
+ descriptionForSha: s__(
+ 'MergeRequest|This description was generated for revision %{revision} using AI',
+ ),
hidePreview: __('Continue editing'),
+ preview: __('Preview'),
},
};
</script>
<template>
- <div class="md-header gl-bg-gray-50 gl-px-2 gl-rounded-base gl-mx-2 gl-mt-2">
- <div
- class="gl-display-flex gl-align-items-center gl-flex-wrap"
- :class="{
- 'gl-justify-content-end': previewMarkdown,
- 'gl-justify-content-space-between': !previewMarkdown,
- }"
- >
+ <div class="md-header gl-border-b gl-border-gray-100 gl-px-3">
+ <div class="gl-display-flex gl-align-items-center gl-flex-wrap">
<div
data-testid="md-header-toolbar"
- class="md-header-toolbar gl-display-flex gl-py-2 gl-flex-wrap"
- :class="{ 'gl-display-none!': previewMarkdown }"
+ class="md-header-toolbar gl-display-flex gl-py-3 gl-flex-wrap gl-row-gap-3"
>
- <template v-if="canSuggest">
+ <gl-button
+ v-if="enablePreview"
+ data-testid="preview-toggle"
+ value="preview"
+ :label="$options.i18n.previewTabTitle"
+ class="js-md-preview-button gl-flex-direction-row-reverse gl-align-items-center gl-font-weight-normal! gl-mr-2"
+ size="small"
+ category="tertiary"
+ @click="switchPreview"
+ >{{ previewMarkdown ? $options.i18n.hidePreview : $options.i18n.preview }}</gl-button
+ >
+ <template v-if="!previewMarkdown && canSuggest">
<toolbar-button
ref="suggestButton"
:tag="mdSuggestion"
@@ -289,11 +311,13 @@ export default {
</gl-popover>
</template>
<ai-actions-dropdown
- v-if="editorAiActions.length"
+ v-if="!previewMarkdown && editorAiActions.length"
:actions="editorAiActions"
- @input="insertIntoTextarea"
+ @input="insertAIAction"
+ @replace="replaceTextarea"
/>
<toolbar-button
+ v-show="!previewMarkdown"
tag="**"
:button-title="
/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
@@ -305,6 +329,7 @@ export default {
icon="bold"
/>
<toolbar-button
+ v-show="!previewMarkdown"
tag="_"
:button-title="
/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
@@ -317,11 +342,13 @@ export default {
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('strikethrough')"
+ v-show="!previewMarkdown"
tag="~~"
:button-title="
/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
- sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}⇧X)'), {
- modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */,
+ sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}%{shiftKey}X)'), {
+ modifierKey,
+ shiftKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */,
})
"
:shortcuts="$options.shortcuts.strikethrough"
@@ -329,14 +356,22 @@ export default {
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('quote')"
+ v-show="!previewMarkdown"
:prepend="true"
:tag="tag"
:button-title="__('Insert a quote')"
icon="quote"
@click="handleQuote"
/>
- <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" />
<toolbar-button
+ v-show="!previewMarkdown"
+ tag="`"
+ tag-block="```"
+ :button-title="__('Insert code')"
+ icon="code"
+ />
+ <toolbar-button
+ v-show="!previewMarkdown"
tag="[{text}](url)"
tag-select="url"
:button-title="
@@ -350,6 +385,7 @@ export default {
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('bullet-list')"
+ v-show="!previewMarkdown"
:prepend="true"
tag="- "
:button-title="__('Add a bullet list')"
@@ -357,6 +393,7 @@ export default {
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('numbered-list')"
+ v-show="!previewMarkdown"
:prepend="true"
tag="1. "
:button-title="__('Add a numbered list')"
@@ -364,6 +401,7 @@ export default {
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('task-list')"
+ v-show="!previewMarkdown"
:prepend="true"
tag="- [ ] "
:button-title="__('Add a checklist')"
@@ -371,6 +409,7 @@ export default {
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('indent')"
+ v-show="!previewMarkdown"
class="gl-display-none"
:button-title="
/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
@@ -384,6 +423,7 @@ export default {
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('outdent')"
+ v-show="!previewMarkdown"
class="gl-display-none"
:button-title="
/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
@@ -397,6 +437,7 @@ export default {
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('collapsible-section')"
+ v-show="!previewMarkdown"
:tag="mdCollapsibleSection"
:prepend="true"
tag-select="Click to expand"
@@ -405,17 +446,18 @@ export default {
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('table')"
+ v-show="!previewMarkdown"
:tag="mdTable"
:prepend="true"
:button-title="__('Add a table')"
icon="table"
/>
<gl-button
- v-if="!restrictedToolBarItems.includes('attach-file')"
+ v-if="!previewMarkdown && !restrictedToolBarItems.includes('attach-file')"
v-gl-tooltip
:aria-label="__('Attach a file or image')"
:title="__('Attach a file or image')"
- class="gl-mr-2"
+ class="gl-mr-3"
data-testid="button-attach-file"
category="tertiary"
icon="paperclip"
@@ -423,46 +465,37 @@ export default {
@click="handleAttachFile"
/>
<drawio-toolbar-button
- v-if="drawioEnabled"
+ v-if="!previewMarkdown && drawioEnabled"
:uploads-path="uploadsPath"
:markdown-preview-path="markdownPreviewPath"
/>
+ <!-- TODO Add icon and trigger functionality from here -->
+ <toolbar-button
+ v-if="supportsQuickActions"
+ v-show="!previewMarkdown"
+ :prepend="true"
+ tag="/"
+ :button-title="__('Add a quick action')"
+ icon="quick-actions"
+ />
<comment-templates-dropdown
- v-if="newCommentTemplatePath && glFeatures.savedReplies"
+ v-if="!previewMarkdown && newCommentTemplatePath && glFeatures.savedReplies"
:new-comment-template-path="newCommentTemplatePath"
+ @select="insertSavedReply"
/>
- </div>
- <div class="switch-preview gl-py-2 gl-display-flex gl-align-items-center gl-ml-auto">
- <editor-mode-switcher
- v-if="showEditorModeSwitcher"
- size="small"
- class="gl-mr-2"
- value="markdown"
- @input="handleEditorModeChanged"
- />
- <gl-button
- v-if="enablePreview"
- data-testid="preview-toggle"
- value="preview"
- :label="$options.i18n.previewTabTitle"
- class="js-md-preview-button gl-flex-direction-row-reverse gl-align-items-center gl-font-weight-normal!"
- size="small"
- category="tertiary"
- @click="switchPreview"
- >{{ previewMarkdown ? $options.i18n.hidePreview : $options.i18n.preview }}</gl-button
- >
- <gl-button
- v-if="!restrictedToolBarItems.includes('full-screen')"
- v-gl-tooltip
- :class="{ 'gl-display-none!': previewMarkdown }"
- class="js-zen-enter gl-ml-2"
- category="tertiary"
- icon="maximize"
- size="small"
- :title="__('Go full screen')"
- :prepend="true"
- :aria-label="__('Go full screen')"
- />
+ <div v-if="!previewMarkdown" class="full-screen">
+ <gl-button
+ v-if="!restrictedToolBarItems.includes('full-screen')"
+ v-gl-tooltip
+ class="js-zen-enter"
+ category="tertiary"
+ icon="maximize"
+ size="small"
+ :title="__('Go full screen')"
+ :prepend="true"
+ :aria-label="__('Go full screen')"
+ />
+ </div>
</div>
</div>
</div>
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 9fd606d775d..8b8247a5b2c 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
@@ -5,6 +5,7 @@ import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { updateDraft, clearDraft, getDraft } from '~/lib/utils/autosave';
import { setUrlParams, joinPaths } from '~/lib/utils/url_utility';
import {
+ EDITING_MODE_KEY,
EDITING_MODE_MARKDOWN_FIELD,
EDITING_MODE_CONTENT_EDITOR,
CLEAR_AUTOSAVE_ENTRY_EVENT,
@@ -80,11 +81,6 @@ export default {
required: false,
default: '',
},
- quickActionsDocsPath: {
- type: String,
- required: false,
- default: '',
- },
drawioEnabled: {
type: Boolean,
required: false,
@@ -100,6 +96,11 @@ export default {
required: false,
default: false,
},
+ codeSuggestionsConfig: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
data() {
return {
@@ -171,7 +172,7 @@ export default {
renderMarkdown(markdown) {
const url = setUrlParams(
{ render_quick_actions: this.supportsQuickActions },
- joinPaths(window.location.origin, gon.relative_url_root, this.renderMarkdownPath),
+ joinPaths(gon.relative_url_root || window.location.origin, this.renderMarkdownPath),
);
return axios.post(url, { text: markdown }).then(({ data }) => data.body);
},
@@ -223,14 +224,15 @@ export default {
}
},
},
+ EDITING_MODE_KEY,
};
</script>
<template>
- <div class="md-area gl-px-0! gl-overflow-hidden">
+ <div class="gl-px-0!">
<local-storage-sync
:value="editingMode"
as-string
- storage-key="gl-markdown-editor-mode"
+ :storage-key="$options.EDITING_MODE_KEY"
@input="onEditingModeRestored"
/>
<markdown-field
@@ -240,12 +242,16 @@ export default {
data-testid="markdown-field"
:markdown-preview-path="renderMarkdownPath"
:can-attach-file="!disableAttachments"
+ :can-suggest="codeSuggestionsConfig.canSuggest"
+ :line="codeSuggestionsConfig.line"
+ :lines="codeSuggestionsConfig.lines"
+ :show-suggest-popover="codeSuggestionsConfig.showPopover"
:textarea-value="markdown"
:uploads-path="uploadsPath"
:enable-autocomplete="enableAutocomplete"
:autocomplete-data-sources="autocompleteDataSources"
:markdown-docs-path="markdownDocsPath"
- :quick-actions-docs-path="quickActionsDocsPath"
+ :supports-quick-actions="supportsQuickActions"
:show-content-editor-switcher="enableContentEditor"
:drawio-enabled="drawioEnabled"
:restricted-tool-bar-items="markdownFieldRestrictedToolBarItems"
@@ -272,9 +278,10 @@ export default {
<content-editor
ref="contentEditor"
:render-markdown="renderMarkdown"
+ :markdown-docs-path="markdownDocsPath"
:uploads-path="uploadsPath"
:markdown="markdown"
- :quick-actions-docs-path="quickActionsDocsPath"
+ :supports-quick-actions="supportsQuickActions"
:autofocus="contentEditorAutofocused"
:placeholder="formFieldProps.placeholder"
:drawio-enabled="drawioEnabled"
@@ -282,6 +289,7 @@ export default {
:autocomplete-data-sources="autocompleteDataSources"
:editable="!disabled"
:disable-attachments="disableAttachments"
+ :code-suggestions-config="codeSuggestionsConfig"
@initialized="setEditorAsAutofocused"
@change="updateMarkdownFromContentEditor"
@keydown="$emit('keydown', $event)"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js
index 8ff14220eab..0b0867ae84c 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js
+++ b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js
@@ -1,6 +1,9 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createApolloClient from '~/lib/graphql';
import { queryToObject, objectToQuery } from '~/lib/utils/url_utility';
import { parseBoolean } from '~/lib/utils/common_utils';
+
import { CLEAR_AUTOSAVE_ENTRY_EVENT } from '../../constants';
import MarkdownEditor from './markdown_editor.vue';
import eventHub from './eventhub';
@@ -51,8 +54,13 @@ function mountAutosaveClearOnSubmit(autosaveKey) {
}
}
-export function mountMarkdownEditor() {
+export function mountMarkdownEditor(options = {}) {
const el = document.querySelector('.js-markdown-editor');
+ const componentConfiguration = {
+ provide: {
+ ...options.provide,
+ },
+ };
if (!el) {
return null;
@@ -71,6 +79,7 @@ export function mountMarkdownEditor() {
const supportsQuickActions = parseBoolean(el.dataset.supportsQuickActions ?? true);
const enableAutocomplete = parseBoolean(el.dataset.enableAutocomplete ?? true);
const disableAttachments = parseBoolean(el.dataset.disableAttachments ?? false);
+ const autofocus = parseBoolean(el.dataset.autofocus ?? true);
const hiddenInput = el.querySelector('input[type="hidden"]');
const formFieldName = hiddenInput.getAttribute('name');
const formFieldId = hiddenInput.getAttribute('id');
@@ -86,6 +95,9 @@ export function mountMarkdownEditor() {
const setFacade = (props) => Object.assign(facade, props);
const autosaveKey = `autosave/${document.location.pathname}/${searchTerm}/description`;
+ componentConfiguration.apolloProvider =
+ options.apolloProvider || new VueApollo({ defaultClient: createApolloClient() });
+
// eslint-disable-next-line no-new
new Vue({
el,
@@ -110,10 +122,11 @@ export function mountMarkdownEditor() {
autocompleteDataSources: gl.GfmAutoComplete?.dataSources,
supportsQuickActions,
disableAttachments,
- autofocus: true,
+ autofocus,
},
});
},
+ ...componentConfiguration,
});
mountAutosaveClearOnSubmit(autosaveKey);
diff --git a/app/assets/javascripts/vue_shared/components/markdown/non_gfm_markdown.stories.js b/app/assets/javascripts/vue_shared/components/markdown/non_gfm_markdown.stories.js
new file mode 100644
index 00000000000..0ba6a44d153
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/non_gfm_markdown.stories.js
@@ -0,0 +1,89 @@
+import Markdown from '~/vue_shared/components/markdown/non_gfm_markdown.vue';
+
+export default {
+ title: 'vue_shared/non_gfm_markdown',
+ component: Markdown,
+ parameters: {
+ docs: {
+ description: {
+ component: `
+This component is designed to render the markdown, which is **not** the GitLab Flavored Markdown.
+
+It renders the code snippets the same way GitLab Flavored Markdown code snippets are rendered
+respecting the user's preferred color scheme and featuring a copy-code button.
+
+This component can be used to render client-side markdown that doesn't have GitLab-specific markdown elements such as issue links.
+`,
+ },
+ },
+ },
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { Markdown },
+ props: Object.keys(argTypes),
+ template: '<markdown v-bind="$props" />',
+});
+
+const textWithCodeblock = `
+#### Here is the text with the code block.
+
+\`\`\`javascript
+function sayHi(name) {
+ console.log('Hi ' + name || 'Mark');
+}
+\`\`\`
+
+It *can* have **formatting** as well
+`;
+
+export const OneCodeBlock = Template.bind({});
+OneCodeBlock.args = { markdown: textWithCodeblock };
+
+const textWithMultipleCodeBlocks = `
+#### Here is the text with the code block.
+
+\`\`\`javascript
+function sayHi(name) {
+ console.log('Hi ' + name || 'Mark');
+}
+\`\`\`
+
+Note that the copy buttons are appearing independently
+
+\`\`\`yaml
+stages:
+ - build
+ - test
+ - deploy
+\`\`\`
+`;
+
+export const MultipleCodeBlocks = Template.bind({});
+MultipleCodeBlocks.args = { markdown: textWithMultipleCodeBlocks };
+
+const textUndefinedLanguage = `
+#### Here is the code block with no language provided.
+
+\`\`\`
+function sayHi(name) {
+ console.log('Hi ' + name || 'Mark');
+}
+\`\`\`
+`;
+
+export const UndefinedLanguage = Template.bind({});
+UndefinedLanguage.args = { markdown: textUndefinedLanguage };
+
+const textCodeOneLiner = `
+#### Here is the text with the one-liner code block.
+
+Note that copy button rendering is ok.
+
+\`\`\`javascript
+const foo = 'bar';
+\`\`\`
+`;
+
+export const CodeOneLiner = Template.bind({});
+CodeOneLiner.args = { markdown: textCodeOneLiner };
diff --git a/app/assets/javascripts/vue_shared/components/markdown/non_gfm_markdown.vue b/app/assets/javascripts/vue_shared/components/markdown/non_gfm_markdown.vue
new file mode 100644
index 00000000000..814e59681d0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/non_gfm_markdown.vue
@@ -0,0 +1,120 @@
+<script>
+/*
+This component is designed to render the markdown, which is **not** the GitLab Flavored Markdown.
+
+It renders the code snippets the same way GitLab Flavored Markdown code snippets are rendered
+respecting the user's preferred color scheme and featuring a copy-code button.
+
+This component can be used to render client-side markdown that doesn't have GitLab-specific markdown elements such as issue links.
+*/
+import { marked } from 'marked';
+import CodeBlockHighlighted from '~/vue_shared/components/code_block_highlighted.vue';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { sanitize } from '~/lib/dompurify';
+import { markdownConfig } from '~/lib/utils/text_utility';
+import { __ } from '~/locale';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+
+export default {
+ components: {
+ CodeBlockHighlighted,
+ ModalCopyButton,
+ },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ markdown: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ hoverMap: {},
+ };
+ },
+ computed: {
+ markdownBlocks() {
+ // we use lexer https://marked.js.org/using_pro#lexer
+ // to get an array of tokens that marked npm module uses.
+ // We will use these tokens to override rendering of some of them
+ // with our vue components
+ const tokens = marked.lexer(this.markdown);
+
+ // since we only want to differentiate between code and non-code blocks
+ // we want non-code blocks merged together so that the markdown parser could render
+ // them according to the markdown rules.
+ // This way we introduce minimum extra wrapper mark-up
+ const flattenedTokens = [];
+
+ for (const token of tokens) {
+ const lastFlattenedToken = flattenedTokens[flattenedTokens.length - 1];
+ if (token.type === 'code') {
+ flattenedTokens.push(token);
+ } else if (lastFlattenedToken?.type === 'markdown') {
+ lastFlattenedToken.raw += token.raw;
+ } else {
+ flattenedTokens.push({ type: 'markdown', raw: token.raw });
+ }
+ }
+
+ return flattenedTokens;
+ },
+ },
+ methods: {
+ getSafeHtml(markdown) {
+ return sanitize(marked.parse(markdown), markdownConfig);
+ },
+ setHoverOn(key) {
+ this.hoverMap = { ...this.hoverMap, [key]: true };
+ },
+ setHoverOff(key) {
+ this.hoverMap = { ...this.hoverMap, [key]: false };
+ },
+ isLastElement(index) {
+ return index === this.markdownBlocks.length - 1;
+ },
+ },
+ safeHtmlConfig: {
+ ADD_TAGS: ['use', 'gl-emoji', 'copy-code'],
+ },
+ i18n: {
+ copyCodeTitle: __('Copy code'),
+ },
+ fallbackLanguage: 'text',
+};
+</script>
+<template>
+ <div>
+ <template v-for="(block, index) in markdownBlocks">
+ <div
+ v-if="block.type === 'code'"
+ :key="`code-${index}`"
+ :class="{ 'gl-relative': true, 'gl-mb-4': !isLastElement(index) }"
+ data-testid="code-block-wrapper"
+ @mouseenter="setHoverOn(`code-${index}`)"
+ @mouseleave="setHoverOff(`code-${index}`)"
+ >
+ <modal-copy-button
+ v-if="hoverMap[`code-${index}`]"
+ :title="$options.i18n.copyCodeTitle"
+ :text="block.text"
+ class="gl-absolute gl-top-3 gl-right-3 gl-z-index-1 gl-transition-duration-medium"
+ />
+ <code-block-highlighted
+ class="gl-border gl-rounded-0! gl-p-4 gl-mb-0 gl-overflow-y-auto"
+ :language="block.lang || $options.fallbackLanguage"
+ :code="block.text"
+ />
+ </div>
+ <div
+ v-else
+ :key="`text-${index}`"
+ v-safe-html:[$options.safeHtmlConfig]="getSafeHtml(block.raw)"
+ :class="{ 'non-gfm-markdown-block': true, 'gl-mb-4': !isLastElement(index) }"
+ data-testid="non-code-markdown"
+ ></div>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 6d1cadf15be..4423b26560f 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -40,7 +40,8 @@ export default {
},
defaultCommitMessage: {
type: String,
- required: true,
+ required: false,
+ default: null,
},
suggestionsCount: {
type: Number,
@@ -124,7 +125,7 @@ export default {
suggestion,
batchSuggestionsInfo,
helpPagePath,
- defaultCommitMessage,
+ defaultCommitMessage: defaultCommitMessage || '',
suggestionsCount,
failedToLoadMetadata,
},
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 4733afb7504..d4b1abedc02 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,24 +1,26 @@
<script>
-import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui';
+import { GlButton, GlLoadingIcon, GlSprintf, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { updateText } from '~/lib/utils/text_markdown';
+import { __, sprintf } from '~/locale';
+import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
+import EditorModeSwitcher from './editor_mode_switcher.vue';
export default {
components: {
GlButton,
- GlLink,
GlLoadingIcon,
GlSprintf,
GlIcon,
+ EditorModeSwitcher,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
markdownDocsPath: {
type: String,
required: true,
},
- quickActionsDocsPath: {
- type: String,
- required: false,
- default: '',
- },
canAttachFile: {
type: Boolean,
required: false,
@@ -29,10 +31,46 @@ export default {
required: false,
default: true,
},
+ showContentEditorSwitcher: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
- hasQuickActionsDocsPath() {
- return this.quickActionsDocsPath !== '';
+ showEditorModeSwitcher() {
+ return this.showContentEditorSwitcher;
+ },
+ },
+ methods: {
+ insertIntoTextarea(...lines) {
+ const text = lines.join('\n');
+ const textArea = this.$el.closest('.md-area')?.querySelector('textarea');
+ if (textArea && !textArea.value) {
+ updateText({
+ textArea,
+ tag: text,
+ cursorOffset: 0,
+ wrap: false,
+ });
+ }
+ },
+ handleEditorModeChanged(isFirstSwitch) {
+ if (isFirstSwitch) {
+ this.insertIntoTextarea(
+ __(`### Rich text editor`),
+ '',
+ sprintf(
+ __(
+ 'Try out **styling** _your_ content right here or read the [direction](%{directionUrl}).',
+ ),
+ {
+ directionUrl: `${PROMO_URL}/direction/plan/knowledge/content_editor/`,
+ },
+ ),
+ );
+ }
+ this.$emit('enableContentEditor');
},
},
};
@@ -41,94 +79,80 @@ export default {
<template>
<div
v-if="showCommentToolBar"
- class="comment-toolbar gl-mx-2 gl-mb-2 gl-px-4 gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base clearfix"
+ class="comment-toolbar gl-display-flex gl-flex-direction-row gl-px-2 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
+ :class="
+ showContentEditorSwitcher
+ ? 'gl-justify-content-space-between gl-align-items-center gl-border-t gl-border-gray-100'
+ : 'gl-justify-content-end gl-my-2'
+ "
>
- <div class="toolbar-text gl-font-sm">
- <template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
- <gl-sprintf
- :message="
- s__('MarkdownToolbar|Supports %{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd}')
- "
- >
- <template #markdownDocsLink="{ content }">
- <gl-link :href="markdownDocsPath" target="_blank" class="gl-font-sm">{{
- content
- }}</gl-link>
- </template>
- </gl-sprintf>
- </template>
- <template v-if="hasQuickActionsDocsPath && markdownDocsPath">
- <gl-sprintf
- :message="
- s__(
- 'NoteToolbar|Supports %{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd}. For %{quickActionsDocsLinkStart}quick actions%{quickActionsDocsLinkEnd}, type %{keyboardStart}/%{keyboardEnd}.',
- )
- "
- >
- <template #markdownDocsLink="{ content }">
- <gl-link :href="markdownDocsPath" target="_blank" class="gl-font-sm">{{
- content
- }}</gl-link>
- </template>
- <template #keyboard="{ content }">
- <kbd>{{ content }}</kbd>
- </template>
- <template #quickActionsDocsLink="{ content }">
- <gl-link :href="quickActionsDocsPath" target="_blank" class="gl-font-sm">{{
- content
- }}</gl-link>
- </template>
- </gl-sprintf>
- </template>
- </div>
- <span v-if="canAttachFile" class="uploading-container gl-font-sm gl-line-height-32">
- <span class="uploading-progress-container hide">
- <gl-icon name="paperclip" />
- <span class="attaching-file-message"></span>
- <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
- <span class="uploading-progress">0%</span>
- <gl-loading-icon size="sm" inline />
- </span>
- <span class="uploading-error-container hide">
- <span class="uploading-error-icon">
+ <editor-mode-switcher
+ v-if="showEditorModeSwitcher"
+ size="small"
+ value="markdown"
+ @switch="handleEditorModeChanged"
+ />
+ <div class="gl-display-flex">
+ <div v-if="canAttachFile" class="uploading-container gl-font-sm gl-line-height-32 gl-mr-3">
+ <span class="uploading-progress-container hide">
<gl-icon name="paperclip" />
+ <span class="attaching-file-message"></span>
+ <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
+ <span class="uploading-progress">0%</span>
+ <gl-loading-icon size="sm" inline />
</span>
- <span class="uploading-error-message"></span>
+ <span class="uploading-error-container hide">
+ <span class="uploading-error-icon">
+ <gl-icon name="paperclip" />
+ </span>
+ <span class="uploading-error-message"></span>
- <gl-sprintf
- :message="
- __(
- '%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}.',
- )
- "
+ <gl-sprintf
+ :message="
+ __(
+ '%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}.',
+ )
+ "
+ >
+ <template #retryButton="{ content }">
+ <gl-button
+ variant="link"
+ category="primary"
+ class="retry-uploading-link gl-vertical-align-baseline gl-font-sm!"
+ >
+ {{ content }}
+ </gl-button>
+ </template>
+ <template #newFileButton="{ content }">
+ <gl-button
+ variant="link"
+ category="primary"
+ class="markdown-selector attach-new-file gl-vertical-align-baseline gl-font-sm!"
+ >
+ {{ content }}
+ </gl-button>
+ </template>
+ </gl-sprintf>
+ </span>
+ <gl-button
+ variant="link"
+ category="primary"
+ class="button-cancel-uploading-files gl-vertical-align-baseline hide gl-font-sm!"
>
- <template #retryButton="{ content }">
- <gl-button
- variant="link"
- category="primary"
- class="retry-uploading-link gl-vertical-align-baseline gl-font-sm!"
- >
- {{ content }}
- </gl-button>
- </template>
- <template #newFileButton="{ content }">
- <gl-button
- variant="link"
- category="primary"
- class="markdown-selector attach-new-file gl-vertical-align-baseline gl-font-sm!"
- >
- {{ content }}
- </gl-button>
- </template>
- </gl-sprintf>
- </span>
+ {{ __('Cancel') }}
+ </gl-button>
+ </div>
<gl-button
- variant="link"
- category="primary"
- class="button-cancel-uploading-files gl-vertical-align-baseline hide gl-font-sm!"
- >
- {{ __('Cancel') }}
- </gl-button>
- </span>
+ v-if="markdownDocsPath"
+ v-gl-tooltip
+ icon="markdown-mark"
+ :href="markdownDocsPath"
+ target="_blank"
+ category="tertiary"
+ size="small"
+ title="Markdown is supported"
+ class="gl-px-3!"
+ />
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/tracking.js b/app/assets/javascripts/vue_shared/components/markdown/tracking.js
new file mode 100644
index 00000000000..2628054ae5f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/tracking.js
@@ -0,0 +1,14 @@
+import Tracking from '~/tracking';
+
+export const EDITOR_TRACKING_LABEL = 'editor_tracking';
+export const EDITOR_TYPE_ACTION = 'editor_type_used';
+export const EDITOR_TYPE_PLAIN_TEXT_EDITOR = 'editor_type_plain_text_editor';
+export const EDITOR_TYPE_RICH_TEXT_EDITOR = 'editor_type_rich_text_editor';
+
+export const trackSavedUsingEditor = (isRichText, context) => {
+ Tracking.event(undefined, EDITOR_TYPE_ACTION, {
+ label: EDITOR_TRACKING_LABEL,
+ editorType: isRichText ? EDITOR_TYPE_RICH_TEXT_EDITOR : EDITOR_TYPE_PLAIN_TEXT_EDITOR,
+ context,
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/components/markdown/utils.js b/app/assets/javascripts/vue_shared/components/markdown/utils.js
new file mode 100644
index 00000000000..0227d5a0fbc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/utils.js
@@ -0,0 +1,7 @@
+let i = 0;
+
+export const counter = () => {
+ const n = i;
+ i += 1;
+ return n;
+};
diff --git a/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue b/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue
index 064458cfc1f..ba557878246 100644
--- a/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue
@@ -51,6 +51,8 @@ export default {
SidebarSubscriptionsWidget,
AbuseCategorySelector,
NewHeaderActionsPopover,
+ SummaryNotesToggle: () =>
+ import('ee_component/merge_requests/components/summary_notes_toggle.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -60,6 +62,9 @@ export default {
reportAbusePath: {
default: '',
},
+ showSummaryNotesToggle: {
+ default: false,
+ },
},
props: {
mr: {
@@ -71,6 +76,11 @@ export default {
default: '',
required: false,
},
+ url: {
+ type: String,
+ default: '',
+ required: false,
+ },
editUrl: {
type: String,
default: '',
@@ -116,11 +126,6 @@ export default {
default: 0,
required: false,
},
- reportedFromUrl: {
- type: String,
- default: '',
- required: false,
- },
},
data() {
return {
@@ -156,7 +161,7 @@ export default {
this.isLoadingDraft = true;
axios
- .put(`?merge_request[wip_event]=${this.draftState}`, null, {
+ .put(`${this.url}?merge_request[wip_event]=${this.draftState}`, null, {
params: { format: 'json' },
})
.then(({ data }) => {
@@ -226,10 +231,12 @@ export default {
:auto-close="false"
>
<template #toggle>
- <div class="gl-min-h-7 gl-mb-2 gl-md-mb-0!" :aria-label="$options.i18n.mergeRequestActions">
+ <div class="gl-min-h-7 gl-mb-2 gl-md-mb-0!">
<gl-button
class="gl-md-display-none! gl-new-dropdown-toggle gl-absolute gl-top-0 gl-left-0 gl-w-full"
category="secondary"
+ :aria-label="$options.i18n.mergeRequestActions"
+ :title="$options.i18n.mergeRequestActions"
>
<span class="">{{ $options.i18n.mergeRequestActions }}</span>
<gl-icon class="dropdown-chevron" name="chevron-down" />
@@ -238,6 +245,8 @@ export default {
class="gl-display-none gl-md-display-flex! gl-new-dropdown-toggle gl-new-dropdown-icon-only gl-new-dropdown-toggle-no-caret gl-ml-3"
category="tertiary"
icon="ellipsis_v"
+ :aria-label="$options.i18n.mergeRequestActions"
+ :title="$options.i18n.mergeRequestActions"
/>
</div>
</template>
@@ -329,6 +338,8 @@ export default {
{{ $options.i18n.copyReferenceText }}
</template>
</gl-disclosure-dropdown-item>
+
+ <summary-notes-toggle v-if="showSummaryNotesToggle" @action="closeActionsDropdown" />
</gl-disclosure-dropdown-group>
<gl-disclosure-dropdown-group
@@ -353,7 +364,7 @@ export default {
<abuse-category-selector
v-if="!isCurrentUser && isReportAbuseDrawerOpen"
:reported-user-id="reportedUserId"
- :reported-from-url="reportedFromUrl"
+ :reported-from-url="url"
:show-drawer="isReportAbuseDrawerOpen"
@close-drawer="reportAbuseAction(false)"
/>
diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
index 57e3a97244e..ab9e6e092d9 100644
--- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
+++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
@@ -116,6 +116,7 @@ export default {
unique: true,
symbol: '@',
token: UserToken,
+ dataType: 'user',
operators: OPERATORS_IS,
fetchPath: this.projectPath,
fetchUsers: Api.projectUsers.bind(Api),
@@ -127,6 +128,7 @@ export default {
unique: true,
symbol: '@',
token: UserToken,
+ dataType: 'user',
operators: OPERATORS_IS,
fetchPath: this.projectPath,
fetchUsers: Api.projectUsers.bind(Api),
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
index 11aa7b91745..cb8220a0407 100644
--- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
@@ -30,12 +30,22 @@ export default {
type: Array,
required: true,
},
+ showProjectIcon: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
};
</script>
<template>
<ul class="gl-p-0 gl-list-style-none">
- <projects-list-item v-for="project in projects" :key="project.id" :project="project" />
+ <projects-list-item
+ v-for="project in projects"
+ :key="project.id"
+ :project="project"
+ :show-project-icon="showProjectIcon"
+ />
</ul>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
index 266cce29e50..d919f76e684 100644
--- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
@@ -34,6 +34,7 @@ export default {
moreTopics: __('More topics'),
updated: __('Updated'),
},
+ avatarSize: { default: 32, md: 48 },
safeHtmlConfig: {
ADD_TAGS: ['gl-emoji'],
},
@@ -78,6 +79,11 @@ export default {
type: Object,
required: true,
},
+ showProjectIcon: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -85,11 +91,14 @@ export default {
};
},
computed: {
+ visibility() {
+ return this.project.visibility;
+ },
visibilityIcon() {
- return VISIBILITY_TYPE_ICON[this.project.visibility];
+ return VISIBILITY_TYPE_ICON[this.visibility];
},
visibilityTooltip() {
- return PROJECT_VISIBILITY_TYPE[this.project.visibility];
+ return PROJECT_VISIBILITY_TYPE[this.visibility];
},
accessLevel() {
return this.project.permissions?.projectAccess?.accessLevel;
@@ -150,71 +159,87 @@ export default {
<template>
<li class="projects-list-item gl-py-5 gl-md-display-flex gl-align-items-center gl-border-b">
- <gl-avatar-labeled
- class="gl-flex-grow-1"
- :entity-id="project.id"
- :entity-name="project.name"
- :label="project.name"
- :label-link="project.webUrl"
- shape="rect"
- :size="48"
- >
- <template #meta>
- <gl-icon
- v-gl-tooltip="visibilityTooltip"
- :name="visibilityIcon"
- class="gl-text-secondary gl-ml-3"
- />
- <user-access-role-badge v-if="shouldShowAccessLevel" class="gl-ml-3">{{
- accessLevelLabel
- }}</user-access-role-badge>
- </template>
- <div
- v-if="project.descriptionHtml"
- v-safe-html:[$options.safeHtmlConfig]="project.descriptionHtml"
- class="gl-font-sm gl-overflow-hidden gl-line-height-20 description"
- data-testid="project-description"
- ></div>
- <div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics">
- <div
- class="gl-w-full gl-display-inline-flex gl-flex-wrap gl-font-base gl-font-weight-normal gl-align-items-center gl-mx-n2 gl-my-n2"
- >
- <span class="gl-p-2 gl-text-secondary">{{ $options.i18n.topics }}:</span>
- <div v-for="topic in visibleTopics" :key="topic" class="gl-p-2">
- <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
- {{ topicTitle(topic) }}
- </gl-badge>
+ <div class="gl-display-flex gl-flex-grow-1">
+ <gl-icon
+ v-if="showProjectIcon"
+ class="gl-mr-3 gl-mt-3 gl-md-mt-5 gl-flex-shrink-0 gl-text-secondary"
+ name="project"
+ />
+ <gl-avatar-labeled
+ :entity-id="project.id"
+ :entity-name="project.name"
+ :label="project.name"
+ :label-link="project.webUrl"
+ shape="rect"
+ :size="$options.avatarSize"
+ >
+ <template #meta>
+ <div class="gl-px-2">
+ <div class="gl-mx-n2 gl-display-flex gl-align-items-center gl-flex-wrap">
+ <div class="gl-px-2">
+ <gl-icon
+ v-if="visibility"
+ v-gl-tooltip="visibilityTooltip"
+ :name="visibilityIcon"
+ class="gl-text-secondary"
+ />
+ </div>
+ <div class="gl-px-2">
+ <user-access-role-badge v-if="shouldShowAccessLevel">{{
+ accessLevelLabel
+ }}</user-access-role-badge>
+ </div>
+ </div>
</div>
- <template v-if="popoverTopics.length">
- <div
- :id="topicsPopoverTarget"
- class="gl-p-2 gl-text-secondary"
- role="button"
- tabindex="0"
- >
- <gl-sprintf :message="$options.i18n.topicsPopoverTargetText">
- <template #count>{{ popoverTopics.length }}</template>
- </gl-sprintf>
+ </template>
+ <div
+ v-if="project.descriptionHtml"
+ v-safe-html:[$options.safeHtmlConfig]="project.descriptionHtml"
+ class="gl-font-sm gl-overflow-hidden gl-line-height-20 description md"
+ data-testid="project-description"
+ ></div>
+ <div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics">
+ <div
+ class="gl-w-full gl-display-inline-flex gl-flex-wrap gl-font-base gl-font-weight-normal gl-align-items-center gl-mx-n2 gl-my-n2"
+ >
+ <span class="gl-p-2 gl-text-secondary">{{ $options.i18n.topics }}:</span>
+ <div v-for="topic in visibleTopics" :key="topic" class="gl-p-2">
+ <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
+ {{ topicTitle(topic) }}
+ </gl-badge>
</div>
- <gl-popover :target="topicsPopoverTarget" :title="$options.i18n.moreTopics">
- <div class="gl-font-base gl-font-weight-normal gl-mx-n2 gl-my-n2">
- <div
- v-for="topic in popoverTopics"
- :key="topic"
- class="gl-p-2 gl-display-inline-block"
- >
- <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
- {{ topicTitle(topic) }}
- </gl-badge>
- </div>
+ <template v-if="popoverTopics.length">
+ <div
+ :id="topicsPopoverTarget"
+ class="gl-p-2 gl-text-secondary"
+ role="button"
+ tabindex="0"
+ >
+ <gl-sprintf :message="$options.i18n.topicsPopoverTargetText">
+ <template #count>{{ popoverTopics.length }}</template>
+ </gl-sprintf>
</div>
- </gl-popover>
- </template>
+ <gl-popover :target="topicsPopoverTarget" :title="$options.i18n.moreTopics">
+ <div class="gl-font-base gl-font-weight-normal gl-mx-n2 gl-my-n2">
+ <div
+ v-for="topic in popoverTopics"
+ :key="topic"
+ class="gl-p-2 gl-display-inline-block"
+ >
+ <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
+ {{ topicTitle(topic) }}
+ </gl-badge>
+ </div>
+ </div>
+ </gl-popover>
+ </template>
+ </div>
</div>
- </div>
- </gl-avatar-labeled>
+ </gl-avatar-labeled>
+ </div>
<div
- class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-pl-10 gl-md-pl-0 gl-md-mt-0"
+ class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-pl-0 gl-md-mt-0"
+ :class="showProjectIcon ? 'gl-pl-11' : 'gl-pl-8'"
>
<div class="gl-display-flex gl-align-items-center gl-gap-x-3">
<gl-badge v-if="project.archived" variant="warning">{{ $options.i18n.archived }}</gl-badge>
@@ -248,7 +273,10 @@ export default {
<span>{{ numberToMetricPrefix(project.openIssuesCount) }}</span>
</gl-link>
</div>
- <div class="gl-font-sm gl-white-space-nowrap gl-text-secondary gl-mt-3">
+ <div
+ v-if="project.updatedAt"
+ class="gl-font-sm gl-white-space-nowrap gl-text-secondary gl-mt-3"
+ >
<span>{{ $options.i18n.updated }}</span>
<time-ago-tooltip :time="project.updatedAt" />
</div>
diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
index 5d0ee6adffe..ccda8c5fea7 100644
--- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
@@ -1,9 +1,13 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
export default {
name: 'ListItem',
components: { GlButton },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
first: {
type: Boolean,
@@ -27,6 +31,9 @@ export default {
detailsSlots: [],
};
},
+ i18n: {
+ toggleDetailsLabel: __('Toggle details'),
+ },
computed: {
optionalClasses() {
return {
@@ -75,10 +82,14 @@ export default {
<slot name="left-primary"></slot>
<gl-button
v-if="detailsSlots.length > 0"
+ v-gl-tooltip
:selected="isDetailsShown"
icon="ellipsis_h"
size="small"
class="gl-ml-2 gl-display-none gl-sm-display-block"
+ :title="$options.i18n.toggleDetailsLabel"
+ :aria-label="$options.i18n.toggleDetailsLabel"
+ :aria-expanded="isDetailsShown"
@click="toggleDetails"
/>
</div>
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
index ff7e803af2a..5d04fd1d8e7 100644
--- 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
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
+import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility';
export default {
components: {
@@ -16,7 +17,7 @@ export default {
'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',
+ HELP_URL: `${DOCS_URL}/runner/install/docker.html`,
};
</script>
<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
index ee41dab0cec..a769b4a6ad8 100644
--- 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
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
+import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility';
export default {
components: {
@@ -16,7 +17,7 @@ export default {
'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',
+ HELP_URL: `${DOCS_URL}/runner/install/kubernetes.html`,
};
</script>
<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 94aa7bd2f88..3b5086b3c7e 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
@@ -122,14 +122,6 @@ export default {
return null;
}
},
- showDeprecationAlert() {
- return (
- // create_runner_workflow_for_admin
- this.glFeatures.createRunnerWorkflowForAdmin ||
- // create_runner_workflow_for_namespace
- this.glFeatures.createRunnerWorkflowForNamespace
- );
- },
},
updated() {
// Refocus on dom changes, after loading data
@@ -200,12 +192,7 @@ export default {
v-on="$listeners"
@shown="onShown"
>
- <gl-alert
- v-if="showDeprecationAlert"
- :title="$options.i18n.deprecationAlertTitle"
- variant="warning"
- :dismissible="false"
- >
+ <gl-alert :title="$options.i18n.deprecationAlertTitle" variant="warning" :dismissible="false">
<gl-sprintf :message="$options.i18n.deprecationAlertContent">
<template #link="{ content }">
<gl-link target="_blank" :href="$options.LEGACY_REGISTER_HELP_URL"
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue
index d77061d4b31..b89fa3f8292 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue
@@ -63,9 +63,6 @@ export default {
shouldHighlight() {
return Boolean(this.highlightedContent) && (this.hasAppeared || this.isHighlighted);
},
- lines() {
- return this.content.split('\n');
- },
pageSearchString() {
const page = getPageParamValue(this.number);
return getPageSearchString(this.blamePath, page);
@@ -123,7 +120,7 @@ export default {
<pre
class="gl-m-0 gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-0"
- ><code v-if="shouldHighlight" v-once v-safe-html="highlightedContent" data-testid="content"></code><code v-else-if="!isLoading" v-once class="line gl-white-space-pre-wrap! gl-ml-1" data-testid="content" v-text="rawContent"></code></pre>
+ ><code v-if="shouldHighlight" v-safe-html="highlightedContent" data-testid="content"></code><code v-else-if="!isLoading" v-once class="line gl-white-space-pre-wrap! gl-ml-1" data-testid="content" v-text="rawContent"></code></pre>
</div>
</gl-intersection-observer>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js
index d694adf7147..3f8a9258fc3 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js
@@ -1,6 +1,7 @@
import wrapChildNodes from './wrap_child_nodes';
import linkDependencies from './link_dependencies';
import wrapBidiChars from './wrap_bidi_chars';
+import wrapLines from './wrap_lines';
export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight';
@@ -11,10 +12,11 @@ export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight';
*
* @param {Object} hljs - the Highlight.js instance.
*/
-export const registerPlugins = (hljs, fileType, rawContent) => {
+export const registerPlugins = (hljs, fileType, rawContent, shouldWrapLines) => {
hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapChildNodes });
hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapBidiChars });
hljs.addPlugin({
[HLJS_ON_AFTER_HIGHLIGHT]: (result) => linkDependencies(result, fileType, rawContent),
});
+ if (shouldWrapLines) hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapLines });
};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js
index a79e88a1132..b972d8ece91 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js
@@ -22,7 +22,7 @@ const format = (node, scope = '') => {
.split(newlineRegex)
.map((newline) => generateHLJSTag(scope, newline, true))
.join('\n');
- } else if (node.scope || node.sublanguage) {
+ } else if (node.children) {
const { children } = node;
if (children.length && children.length === 1) {
buffer += format(children[0], node.scope);
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_lines.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_lines.js
new file mode 100644
index 00000000000..384ada30001
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_lines.js
@@ -0,0 +1,20 @@
+/**
+ * Highlight.js plugin for wrapping lines in the correct classes and attributes.
+ * Needed for things like hash highlighting to work.
+ *
+ * Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst
+ *
+ * @param {Object} Result - an object that represents the highlighted result from Highlight.js
+ */
+
+function wrapLine(content, number, language) {
+ return `<div id="LC${number}" lang="${language}" class="line">${content}</div>`;
+}
+
+export default (result) => {
+ // eslint-disable-next-line no-param-reassign
+ result.value = result.value
+ .split(/\r?\n/)
+ .map((content, index) => wrapLine(content, index + 1, result.language))
+ .join('\n');
+};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue
index 7e18c8414d5..8e4c438719e 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue
@@ -2,6 +2,7 @@
import SafeHtml from '~/vue_shared/directives/safe_html';
import Tracking from '~/tracking';
import addBlobLinksTracking from '~/blob/blob_links_tracking';
+import LineHighlighter from '~/blob/line_highlighter';
import { EVENT_ACTION, EVENT_LABEL_VIEWER } from './constants';
import Chunk from './components/chunk_new.vue';
@@ -19,9 +20,6 @@ export default {
SafeHtml,
},
mixins: [Tracking.mixin()],
- inject: {
- highlightWorker: { default: null },
- },
props: {
blob: {
type: Object,
@@ -33,6 +31,11 @@ export default {
default: () => [],
},
},
+ data() {
+ return {
+ lineHighlighter: new LineHighlighter(),
+ };
+ },
created() {
this.track(EVENT_ACTION, { label: EVENT_LABEL_VIEWER, property: this.blob.language });
addBlobLinksTracking();
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js
index 142c135e9c1..8d8e945cd5f 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js
@@ -1,9 +1,13 @@
-import hljs from 'highlight.js';
+import hljs from 'highlight.js/lib/core';
+import json from 'highlight.js/lib/languages/json';
import { registerPlugins } from '../plugins/index';
import { LINES_PER_CHUNK, NEWLINE, ROUGE_TO_HLJS_LANGUAGE_MAP } from '../constants';
-const initHighlightJs = (fileType, content) => {
- registerPlugins(hljs, fileType, content);
+const initHighlightJs = (fileType, content, language) => {
+ // The Highlight Worker is currently scoped to JSON files.
+ // See the following issue for more: https://gitlab.com/gitlab-org/gitlab/-/issues/415753
+ hljs.registerLanguage(language, json);
+ registerPlugins(hljs, fileType, content, true);
};
const splitByLineBreaks = (content = '') => content.split(/\r?\n/);
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight.js b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_worker.js
index 535e857d7a9..535e857d7a9 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_worker.js
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
index 00720f27934..0949071d4dc 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
@@ -18,6 +18,7 @@
*/
import { GlAvatarLink, GlTooltipDirective } from '@gitlab/ui';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import UserAvatarImage from './user_avatar_image.vue';
export default {
@@ -74,6 +75,16 @@ export default {
required: false,
default: 'top',
},
+ popoverUserId: {
+ type: [String, Number],
+ required: false,
+ default: '',
+ },
+ popoverUsername: {
+ type: String,
+ required: false,
+ default: '',
+ },
username: {
type: String,
required: false,
@@ -81,10 +92,17 @@ export default {
},
},
computed: {
+ userId() {
+ return getIdFromGraphQLId(this.popoverUserId);
+ },
shouldShowUsername() {
return this.username.length > 0;
},
avatarTooltipText() {
+ // Prevent showing tooltip when popoverUserId is present
+ if (this.popoverUserId) {
+ return '';
+ }
return this.shouldShowUsername ? '' : this.tooltipText;
},
},
@@ -92,7 +110,12 @@ export default {
</script>
<template>
- <gl-avatar-link :href="linkHref" class="user-avatar-link">
+ <gl-avatar-link
+ :href="linkHref"
+ :data-user-id="userId"
+ :data-username="popoverUsername"
+ class="user-avatar-link js-user-link"
+ >
<user-avatar-image
:class="imgCssWrapperClasses"
:img-src="imgSrc"
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
index 335f9ab1df4..258e8b1a6c5 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
@@ -81,6 +81,8 @@ export default {
:img-alt="item.name"
:tooltip-text="item.name"
:img-size="imgSize"
+ :popover-user-id="item.id"
+ :popover-username="item.username"
img-css-classes="gl-mr-3"
/>
<template v-if="hasBreakpoint">
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index e09f193310b..446c8c97df0 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -91,6 +91,9 @@ export default {
return '';
},
+ userCannotMerge() {
+ return this.target.dataset.cannotMerge;
+ },
userIsLoading() {
return !this.user?.loaded;
},
@@ -123,6 +126,15 @@ export default {
username() {
return `@${this.user?.username}`;
},
+ cssClasses() {
+ const classList = ['user-popover', 'gl-max-w-48', 'gl-overflow-hidden'];
+
+ if (this.userCannotMerge) {
+ classList.push('user-popover-cannot-merge');
+ }
+
+ return classList;
+ },
},
methods: {
async toggleFollow() {
@@ -181,7 +193,7 @@ export default {
<template>
<!-- Delayed so not every mouseover triggers Popover -->
<gl-popover
- :css-classes="['gl-max-w-48']"
+ :css-classes="cssClasses"
:show="show"
:target="target"
:delay="$options.USER_POPOVER_DELAY"
@@ -190,6 +202,12 @@ export default {
triggers="hover focus manual"
data-testid="user-popover"
>
+ <template v-if="userCannotMerge" #title>
+ <div class="gl-pb-3 gl-display-flex gl-align-items-center" data-testid="cannot-merge">
+ <gl-icon name="warning-solid" class="gl-mr-2 gl-text-orange-400" />
+ <span class="gl-font-weight-normal">{{ __('Cannot merge') }}</span>
+ </div>
+ </template>
<div class="gl-mb-3">
<div v-if="userIsLoading" class="gl-w-20">
<gl-skeleton-loader :width="160" :height="64">
@@ -204,6 +222,7 @@ export default {
:src="user.avatarUrl"
:label="user.name"
:sub-label="username"
+ class="gl-w-full"
>
<template v-if="isBlocked">
<span class="gl-mt-4 gl-font-style-italic">{{ $options.I18N_USER_BLOCKED }}</span>
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 82f4edcbd5f..9a06c0ecf30 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -23,6 +23,7 @@ export const i18n = {
};
export default {
+ name: 'CEWebIdeLink',
components: {
ActionsButton,
GlModal,
@@ -319,7 +320,11 @@ export default {
:toggle-text="$options.i18n.toggleText"
:variant="isBlob ? 'confirm' : 'default'"
:category="isBlob ? 'primary' : 'secondary'"
- />
+ @hidden="$emit('hidden')"
+ @shown="$emit('shown')"
+ >
+ <slot></slot>
+ </actions-button>
<gl-modal
v-if="computedShowGitpodButton && !gitpodEnabled"
v-model="showEnableGitpodModal"
diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js
index 3896e963a53..8946a02e663 100644
--- a/app/assets/javascripts/vue_shared/constants.js
+++ b/app/assets/javascripts/vue_shared/constants.js
@@ -94,6 +94,7 @@ export const confidentialityInfoText = (workspaceType, issuableType) =>
},
);
+export const EDITING_MODE_KEY = 'gl-markdown-editor-mode';
export const EDITING_MODE_MARKDOWN_FIELD = 'markdownField';
export const EDITING_MODE_CONTENT_EDITOR = 'contentEditor';
export const CLEAR_AUTOSAVE_ENTRY_EVENT = 'markdown_clear_autosave_entry';
diff --git a/app/assets/javascripts/vue_shared/global_search/constants.js b/app/assets/javascripts/vue_shared/global_search/constants.js
index 4211b9578a2..a693d4f114d 100644
--- a/app/assets/javascripts/vue_shared/global_search/constants.js
+++ b/app/assets/javascripts/vue_shared/global_search/constants.js
@@ -75,3 +75,23 @@ export const SEARCH_RESULTS_ORDER = [
HELP_CATEGORY,
];
export const DROPDOWN_ORDER = SEARCH_RESULTS_ORDER;
+
+export const SEARCH_LABELS = s__('GlobalSearch|Search labels');
+
+export const DROPDOWN_HEADER = s__('GlobalSearch|Labels');
+
+export const AGGREGATIONS_ERROR_MESSAGE = s__('GlobalSearch|Fetching aggregations error.');
+
+export const NO_LABELS_FOUND = s__('GlobalSearch|No labels found');
+
+export const I18N = {
+ SEARCH_DESCRIBED_BY_DEFAULT,
+ SEARCH_RESULTS_LOADING,
+ SEARCH_DESCRIBED_BY_UPDATED,
+ SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN,
+ SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN,
+ SEARCH_LABELS,
+ DROPDOWN_HEADER,
+ AGGREGATIONS_ERROR_MESSAGE,
+ NO_LABELS_FOUND,
+};
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
index 3d4eebb9524..53e976d698b 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
@@ -9,14 +9,16 @@ import {
} from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { STATUS_OPEN } from '~/issues/constants';
+import { issuableStatusText, STATUS_OPEN } from '~/issues/constants';
import { isExternal } from '~/lib/utils/url_utility';
import { n__, sprintf } from '~/locale';
+import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
export default {
components: {
+ ConfidentialityBadge,
GlIcon,
GlBadge,
GlButton,
@@ -77,8 +79,16 @@ export default {
required: false,
default: false,
},
+ workspaceType: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
+ badgeText() {
+ return issuableStatusText[this.issuableState];
+ },
badgeVariant() {
return this.issuableState === STATUS_OPEN ? 'success' : 'info';
},
@@ -109,6 +119,7 @@ export default {
},
methods: {
handleRightSidebarToggleClick() {
+ this.$emit('toggle');
if (this.toggleSidebarButtonEl) {
this.toggleSidebarButtonEl.dispatchEvent(new Event('click'));
}
@@ -118,21 +129,23 @@ export default {
</script>
<template>
- <div class="detail-page-header">
+ <div class="detail-page-header gl-flex-direction-column gl-sm-flex-direction-row">
<div class="detail-page-header-body">
<gl-badge class="issuable-status-badge gl-mr-3" :variant="badgeVariant">
<gl-icon v-if="statusIcon" :name="statusIcon" :class="statusIconClass" />
- <span class="gl-display-none gl-sm-display-block"><slot name="status-badge"></slot></span>
+ <span class="gl-display-none gl-sm-display-block gl-ml-2">
+ <slot name="status-badge">{{ badgeText }}</slot>
+ </span>
</gl-badge>
- <div class="issuable-meta gl-display-flex! gl-align-items-center">
- <div v-if="blocked || confidential" class="gl-display-inline-block">
- <div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline">
- <gl-icon name="lock" :aria-label="__('Blocked')" />
- </div>
- <div v-if="confidential" data-testid="confidential" class="issuable-warning-icon inline">
- <gl-icon name="eye-slash" :aria-label="__('Confidential')" />
- </div>
+ <div class="issuable-meta gl-display-flex! gl-align-items-center gl-flex-wrap">
+ <div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline">
+ <gl-icon name="lock" :aria-label="__('Blocked')" />
</div>
+ <confidentiality-badge
+ v-if="confidential"
+ :issuable-type="issuableType"
+ :workspace-type="workspaceType"
+ />
<span>
<template v-if="showWorkItemTypeIcon">
<work-item-type-icon :work-item-type="issuableType" show-text />
@@ -182,10 +195,7 @@ export default {
@click="handleRightSidebarToggleClick"
/>
</div>
- <div
- data-testid="header-actions"
- class="detail-page-header-actions gl-display-flex gl-md-display-block"
- >
+ <div data-testid="header-actions" class="detail-page-header-actions gl-display-flex">
<slot name="header-actions"></slot>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
index c33e803c7e1..841d92fd63d 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
@@ -81,9 +81,12 @@ export default {
data-testid="header"
>
<div
- class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5"
+ class="issue-sticky-header-text gl-display-flex gl-align-items-baseline gl-mx-auto gl-px-5"
>
- <gl-badge class="gl-white-space-nowrap gl-mr-3" :variant="badgeVariant">
+ <gl-badge
+ class="gl-white-space-nowrap gl-mr-3 gl-align-self-center"
+ :variant="badgeVariant"
+ >
<gl-icon v-if="statusIcon" class="gl-sm-display-none" :name="statusIcon" />
<span class="gl-display-none gl-sm-display-block">
<slot name="status-badge"></slot>
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 caa85d3eaaf..1b4da047057 100644
--- a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
@@ -1,11 +1,7 @@
<script>
-import SafeHtml from '~/vue_shared/directives/safe_html';
import Tracking from '~/tracking';
export default {
- directives: {
- SafeHtml,
- },
mixins: [Tracking.mixin()],
props: {
title: {
@@ -38,9 +34,10 @@ export default {
@click="track('click_tab', { label: panel.name })"
>
<div
- v-safe-html="panel.illustration"
- class="new-namespace-panel-illustration gl-text-white gl-display-flex gl-flex-shrink-0 gl-justify-content-center"
- ></div>
+ class="new-namespace-panel-illustration gl-display-flex gl-flex-shrink-0 gl-justify-content-center"
+ >
+ <img aria-hidden :src="panel.imageSrc" />
+ </div>
<div class="gl-pl-4">
<h3 class="gl-font-size-h2 gl-reset-color">
{{ panel.title }}
diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
index 5ab2e346a7a..4503ba6e561 100644
--- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
@@ -1,6 +1,5 @@
<script>
import { GlBreadcrumb, GlIcon } from '@gitlab/ui';
-import SafeHtml from '~/vue_shared/directives/safe_html';
import NewTopLevelGroupAlert from '~/groups/components/new_top_level_group_alert.vue';
import SuperSidebarToggle from '~/super_sidebar/components/super_sidebar_toggle.vue';
@@ -18,9 +17,6 @@ export default {
LegacyContainer,
SuperSidebarToggle,
},
- directives: {
- SafeHtml,
- },
props: {
title: {
type: String,
@@ -137,7 +133,9 @@ export default {
<template v-if="activePanel">
<div class="gl-display-flex gl-align-items-center gl-py-5">
- <div v-safe-html="activePanel.illustration" class="gl-text-white col-auto"></div>
+ <div class="col-auto">
+ <img aria-hidden :src="activePanel.imageSrc" />
+ </div>
<div class="col">
<h4>{{ activePanel.title }}</h4>
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue
deleted file mode 100644
index 4c2b082242b..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue
+++ /dev/null
@@ -1,91 +0,0 @@
-<script>
-import { reportTypeToSecurityReportTypeEnum } from 'ee_else_ce/vue_shared/security_reports/constants';
-import { createAlert } from '~/alert';
-import { s__ } from '~/locale';
-import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue';
-import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql';
-import { extractSecurityReportArtifactsFromMergeRequest } from '~/vue_shared/security_reports/utils';
-
-export default {
- components: {
- SecurityReportDownloadDropdown,
- },
- props: {
- reportTypes: {
- type: Array,
- required: true,
- validator: (reportType) => {
- return reportType.every((report) => reportTypeToSecurityReportTypeEnum[report]);
- },
- },
- targetProjectFullPath: {
- type: String,
- required: true,
- },
- mrIid: {
- type: Number,
- required: true,
- },
- injectedArtifacts: {
- type: Array,
- required: false,
- default: () => [],
- },
- },
- data() {
- return {
- reportArtifacts: [],
- };
- },
- apollo: {
- reportArtifacts: {
- query: securityReportMergeRequestDownloadPathsQuery,
- variables() {
- return {
- projectPath: this.targetProjectFullPath,
- iid: String(this.mrIid),
- reportTypes: this.reportTypes.map(
- (reportType) => reportTypeToSecurityReportTypeEnum[reportType],
- ),
- };
- },
- update(data) {
- return extractSecurityReportArtifactsFromMergeRequest(this.reportTypes, data);
- },
- error(error) {
- this.showError(error);
- },
- },
- },
- computed: {
- isLoadingReportArtifacts() {
- return this.$apollo.queries.reportArtifacts.loading;
- },
- mergedReportArtifacts() {
- return [...this.reportArtifacts, ...this.injectedArtifacts];
- },
- },
- methods: {
- showError(error) {
- createAlert({
- message: this.$options.i18n.apiError,
- captureError: true,
- error,
- });
- },
- },
- i18n: {
- apiError: s__(
- 'SecurityReports|Failed to get security report information. Please reload the page or try again later.',
- ),
- },
-};
-</script>
-
-<template>
- <security-report-download-dropdown
- :title="s__('SecurityReports|Download results')"
- :artifacts="mergedReportArtifacts"
- :loading="isLoadingReportArtifacts"
- />
-</template>
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/constants.js b/app/assets/javascripts/vue_shared/security_reports/components/constants.js
deleted file mode 100644
index 5e8199c1bcd..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/components/constants.js
+++ /dev/null
@@ -1,8 +0,0 @@
-export const SEVERITY_CLASS_NAME_MAP = {
- critical: 'gl-text-red-800',
- high: 'gl-text-red-600',
- medium: 'gl-text-orange-400',
- low: 'gl-text-orange-300',
- info: 'gl-text-blue-400',
- unknown: 'gl-text-gray-400',
-};
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue
deleted file mode 100644
index eed1c86c318..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue
+++ /dev/null
@@ -1,58 +0,0 @@
-<script>
-import { GlButton, GlIcon, GlLink, GlPopover } from '@gitlab/ui';
-import { s__ } from '~/locale';
-
-export default {
- components: {
- GlButton,
- GlIcon,
- GlLink,
- GlPopover,
- },
- props: {
- helpPath: {
- type: String,
- required: true,
- },
- discoverProjectSecurityPath: {
- type: String,
- required: false,
- default: '',
- },
- },
- i18n: {
- securityReportsHelp: s__('SecurityReports|Security reports help page link'),
- upgradeToManageVulnerabilities: s__('SecurityReports|Upgrade to manage vulnerabilities'),
- upgradeToInteract: s__(
- 'SecurityReports|Upgrade to interact, track and shift left with vulnerability management features in the UI.',
- ),
- },
-};
-</script>
-
-<template>
- <span v-if="discoverProjectSecurityPath">
- <gl-button
- ref="discoverProjectSecurity"
- icon="question-o"
- category="tertiary"
- :aria-label="$options.i18n.upgradeToManageVulnerabilities"
- />
-
- <gl-popover
- :target="() => $refs.discoverProjectSecurity.$el"
- :title="$options.i18n.upgradeToManageVulnerabilities"
- placement="top"
- triggers="click blur"
- >
- {{ $options.i18n.upgradeToInteract }}
- <gl-link :href="discoverProjectSecurityPath" target="_blank" class="gl-font-sm">{{
- __('Learn more')
- }}</gl-link>
- </gl-popover>
- </span>
-
- <gl-link v-else target="_blank" :href="helpPath" :aria-label="$options.i18n.securityReportsHelp">
- <gl-icon name="question-o" />
- </gl-link>
-</template>
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue
deleted file mode 100644
index e3aa25a294e..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue
+++ /dev/null
@@ -1,59 +0,0 @@
-<script>
-import { GlSprintf } from '@gitlab/ui';
-import { SEVERITY_CLASS_NAME_MAP } from './constants';
-
-export default {
- components: {
- GlSprintf,
- },
- props: {
- message: {
- type: Object,
- required: true,
- },
- },
- computed: {
- shouldShowCountMessage() {
- return !this.message.status && Boolean(this.message.countMessage);
- },
- },
- methods: {
- getSeverityClass(severity) {
- return SEVERITY_CLASS_NAME_MAP[severity];
- },
- },
- slotNames: ['critical', 'high', 'other'],
- spacingClasses: {
- critical: 'gl-pl-4',
- high: 'gl-px-2',
- other: 'gl-px-2',
- },
-};
-</script>
-
-<template>
- <span>
- <gl-sprintf :message="message.message">
- <template #total="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- <span v-if="shouldShowCountMessage" class="gl-font-sm">
- <gl-sprintf :message="message.countMessage">
- <template v-for="slotName in $options.slotNames" #[slotName]="{ content }">
- <span :key="slotName">
- <strong
- v-if="message[slotName] > 0"
- :class="[getSeverityClass(slotName), $options.spacingClasses[slotName]]"
- >
- {{ content }}
- </strong>
- <span v-else :class="$options.spacingClasses[slotName]">
- {{ content }}
- </span>
- </span>
- </template>
- </gl-sprintf>
- </span>
- </span>
-</template>
diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js
index a1d75e08be9..56c6affebd7 100644
--- a/app/assets/javascripts/vue_shared/security_reports/constants.js
+++ b/app/assets/javascripts/vue_shared/security_reports/constants.js
@@ -28,7 +28,6 @@ export const REPORT_TYPE_CLUSTER_IMAGE_SCANNING = 'cluster_image_scanning';
export const REPORT_TYPE_COVERAGE_FUZZING = 'coverage_fuzzing';
export const REPORT_TYPE_CORPUS_MANAGEMENT = 'corpus_management';
export const REPORT_TYPE_API_FUZZING = 'api_fuzzing';
-export const REPORT_TYPE_MANUALLY_ADDED = 'generic';
/**
* SecurityReportTypeEnum values for use with GraphQL.
diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
deleted file mode 100644
index 0cff5edf628..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
+++ /dev/null
@@ -1,238 +0,0 @@
-<script>
-import { mapActions, mapGetters } from 'vuex';
-import { createAlert } from '~/alert';
-import { s__ } from '~/locale';
-import ReportSection from '~/ci/reports/components/report_section.vue';
-import { ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/ci/reports/constants';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import HelpIcon from './components/help_icon.vue';
-import SecurityReportDownloadDropdown from './components/security_report_download_dropdown.vue';
-import SecuritySummary from './components/security_summary.vue';
-import {
- REPORT_TYPE_SAST,
- REPORT_TYPE_SECRET_DETECTION,
- reportTypeToSecurityReportTypeEnum,
-} from './constants';
-import securityReportMergeRequestDownloadPathsQuery from './graphql/queries/security_report_merge_request_download_paths.query.graphql';
-import store from './store';
-import { MODULE_SAST, MODULE_SECRET_DETECTION } from './store/constants';
-import { extractSecurityReportArtifactsFromMergeRequest } from './utils';
-
-export default {
- store,
- components: {
- ReportSection,
- HelpIcon,
- SecurityReportDownloadDropdown,
- SecuritySummary,
- },
- mixins: [glFeatureFlagsMixin()],
- props: {
- pipelineId: {
- type: Number,
- required: true,
- },
- projectId: {
- type: Number,
- required: true,
- },
- securityReportsDocsPath: {
- type: String,
- required: true,
- },
- discoverProjectSecurityPath: {
- type: String,
- required: false,
- default: '',
- },
- sastComparisonPath: {
- type: String,
- required: false,
- default: '',
- },
- secretDetectionComparisonPath: {
- type: String,
- required: false,
- default: '',
- },
- targetProjectFullPath: {
- type: String,
- required: false,
- default: '',
- },
- mrIid: {
- type: Number,
- required: false,
- default: 0,
- },
- canDiscoverProjectSecurity: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- availableSecurityReports: [],
- canShowCounts: false,
-
- // When core_security_mr_widget_counts is not enabled, the
- // error state is shown even when successfully loaded, since success
- // state suggests that the security scans detected no security problems,
- // which is not necessarily the case. A future iteration will actually
- // check whether problems were found and display the appropriate status.
- status: ERROR,
- };
- },
- apollo: {
- reportArtifacts: {
- query: securityReportMergeRequestDownloadPathsQuery,
- variables() {
- return {
- projectPath: this.targetProjectFullPath,
- iid: String(this.mrIid),
- reportTypes: this.$options.reportTypes.map(
- (reportType) => reportTypeToSecurityReportTypeEnum[reportType],
- ),
- };
- },
- update(data) {
- return extractSecurityReportArtifactsFromMergeRequest(this.$options.reportTypes, data);
- },
- error(error) {
- this.showError(error);
- },
- result({ loading, data }) {
- if (loading || !data) {
- return;
- }
-
- // Query has completed, so populate the availableSecurityReports.
- this.onCheckingAvailableSecurityReports(
- this.reportArtifacts.map(({ reportType }) => reportType),
- );
- },
- },
- },
- computed: {
- ...mapGetters(['groupedSummaryText', 'summaryStatus']),
- hasSecurityReports() {
- return this.availableSecurityReports.length > 0;
- },
- hasSastReports() {
- return this.availableSecurityReports.includes(REPORT_TYPE_SAST);
- },
- hasSecretDetectionReports() {
- return this.availableSecurityReports.includes(REPORT_TYPE_SECRET_DETECTION);
- },
- isLoadingReportArtifacts() {
- return this.$apollo.queries.reportArtifacts.loading;
- },
- },
- methods: {
- ...mapActions(MODULE_SAST, {
- setSastDiffEndpoint: 'setDiffEndpoint',
- fetchSastDiff: 'fetchDiff',
- }),
- ...mapActions(MODULE_SECRET_DETECTION, {
- setSecretDetectionDiffEndpoint: 'setDiffEndpoint',
- fetchSecretDetectionDiff: 'fetchDiff',
- }),
- fetchCounts() {
- if (!this.glFeatures.coreSecurityMrWidgetCounts) {
- return;
- }
-
- if (this.sastComparisonPath && this.hasSastReports) {
- this.setSastDiffEndpoint(this.sastComparisonPath);
- this.fetchSastDiff();
- this.canShowCounts = true;
- }
-
- if (this.secretDetectionComparisonPath && this.hasSecretDetectionReports) {
- this.setSecretDetectionDiffEndpoint(this.secretDetectionComparisonPath);
- this.fetchSecretDetectionDiff();
- this.canShowCounts = true;
- }
- },
- onCheckingAvailableSecurityReports(availableSecurityReports) {
- this.availableSecurityReports = availableSecurityReports;
- this.fetchCounts();
- },
- showError(error) {
- createAlert({
- message: this.$options.i18n.apiError,
- captureError: true,
- error,
- });
- },
- },
- reportTypes: [REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION],
- 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'),
- },
- summarySlots: [SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR],
-};
-</script>
-<template>
- <report-section
- v-if="canShowCounts"
- :status="summaryStatus"
- :has-issues="false"
- class="mr-widget-border-top mr-report"
- data-testid="security-mr-widget"
- track-action="users_expanding_secure_security_report"
- >
- <template v-for="slot in $options.summarySlots" #[slot]>
- <span :key="slot">
- <security-summary :message="groupedSummaryText" />
-
- <help-icon
- class="gl-ml-3"
- :help-path="securityReportsDocsPath"
- :discover-project-security-path="discoverProjectSecurityPath"
- />
- </span>
- </template>
-
- <template #action-buttons>
- <security-report-download-dropdown
- :text="s__('SecurityReports|Download results')"
- :artifacts="reportArtifacts"
- :loading="isLoadingReportArtifacts"
- />
- </template>
- </report-section>
-
- <!-- TODO: Remove this section when removing core_security_mr_widget_counts
- feature flag. See https://gitlab.com/gitlab-org/gitlab/-/issues/284097 -->
- <report-section
- v-else-if="hasSecurityReports"
- :status="status"
- :has-issues="false"
- class="mr-widget-border-top mr-report"
- data-testid="security-mr-widget"
- track-action="users_expanding_secure_security_report"
- >
- <template #error>
- {{ $options.i18n.scansHaveRun }}
-
- <help-icon
- class="gl-ml-3"
- :help-path="securityReportsDocsPath"
- :discover-project-security-path="discoverProjectSecurityPath"
- />
- </template>
-
- <template #action-buttons>
- <security-report-download-dropdown
- :text="s__('SecurityReports|Download results')"
- :artifacts="reportArtifacts"
- :loading="isLoadingReportArtifacts"
- />
- </template>
- </report-section>
-</template>
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/constants.js b/app/assets/javascripts/vue_shared/security_reports/store/constants.js
deleted file mode 100644
index 6aeab56eea2..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/store/constants.js
+++ /dev/null
@@ -1,7 +0,0 @@
-/**
- * Vuex module names corresponding to security scan types. These are similar to
- * the snake_case report types from the backend, but should not be considered
- * to be equivalent.
- */
-export const MODULE_SAST = 'sast';
-export const MODULE_SECRET_DETECTION = 'secretDetection';
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/getters.js b/app/assets/javascripts/vue_shared/security_reports/store/getters.js
deleted file mode 100644
index c274f531139..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/store/getters.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import { s__, sprintf } from '~/locale';
-import { LOADING, ERROR, SUCCESS } from '~/ci/reports/constants';
-import { TRANSLATION_IS_LOADING } from './messages';
-import { countVulnerabilities, groupedTextBuilder } from './utils';
-
-export const summaryCounts = (state) =>
- countVulnerabilities(
- state.reportTypes.reduce((acc, reportType) => {
- acc.push(...state[reportType].newIssues);
- return acc;
- }, []),
- );
-
-export const groupedSummaryText = (state, getters) => {
- const reportType = s__('ciReport|Security scanning');
- let status = '';
-
- // All reports are loading
- if (getters.areAllReportsLoading) {
- return { message: sprintf(TRANSLATION_IS_LOADING, { reportType }) };
- }
-
- // All reports returned error
- if (getters.allReportsHaveError) {
- return { message: s__('ciReport|Security scanning failed loading any results') };
- }
-
- if (getters.areReportsLoading && getters.anyReportHasError) {
- status = s__('ciReport|is loading, errors when loading results');
- } else if (getters.areReportsLoading && !getters.anyReportHasError) {
- status = s__('ciReport|is loading');
- } else if (!getters.areReportsLoading && getters.anyReportHasError) {
- status = s__('ciReport|: Loading resulted in an error');
- }
-
- const { critical, high, other } = getters.summaryCounts;
-
- return groupedTextBuilder({ reportType, status, critical, high, other });
-};
-
-export const summaryStatus = (state, getters) => {
- if (getters.areReportsLoading) {
- return LOADING;
- }
-
- if (getters.anyReportHasError || getters.anyReportHasIssues) {
- return ERROR;
- }
-
- return SUCCESS;
-};
-
-export const areReportsLoading = (state) =>
- state.reportTypes.some((reportType) => state[reportType].isLoading);
-
-export const areAllReportsLoading = (state) =>
- state.reportTypes.every((reportType) => state[reportType].isLoading);
-
-export const allReportsHaveError = (state) =>
- state.reportTypes.every((reportType) => state[reportType].hasError);
-
-export const anyReportHasError = (state) =>
- state.reportTypes.some((reportType) => state[reportType].hasError);
-
-export const anyReportHasIssues = (state) =>
- state.reportTypes.some((reportType) => state[reportType].newIssues.length > 0);
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/index.js b/app/assets/javascripts/vue_shared/security_reports/store/index.js
deleted file mode 100644
index 164faa86744..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/store/index.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import Vuex from 'vuex';
-import { MODULE_SAST, MODULE_SECRET_DETECTION } from './constants';
-import * as getters from './getters';
-import sast from './modules/sast';
-import secretDetection from './modules/secret_detection';
-import state from './state';
-
-export default () =>
- new Vuex.Store({
- modules: {
- [MODULE_SAST]: sast,
- [MODULE_SECRET_DETECTION]: secretDetection,
- },
- getters,
- state,
- });
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/messages.js b/app/assets/javascripts/vue_shared/security_reports/store/messages.js
deleted file mode 100644
index c25e252a768..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/store/messages.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import { s__ } from '~/locale';
-
-export const TRANSLATION_IS_LOADING = s__('ciReport|%{reportType} is loading');
-export const TRANSLATION_HAS_ERROR = s__('ciReport|%{reportType}: Loading resulted in an error');
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js
deleted file mode 100644
index 8aefc13a5fa..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants';
-import { fetchDiffData } from '../../utils';
-import * as types from './mutation_types';
-
-export const setDiffEndpoint = ({ commit }, path) => commit(types.SET_DIFF_ENDPOINT, path);
-
-export const requestDiff = ({ commit }) => commit(types.REQUEST_DIFF);
-
-export const receiveDiffSuccess = ({ commit }, response) =>
- commit(types.RECEIVE_DIFF_SUCCESS, response);
-
-export const receiveDiffError = ({ commit }, response) =>
- commit(types.RECEIVE_DIFF_ERROR, response);
-
-export const fetchDiff = ({ state, rootState, dispatch }) => {
- dispatch('requestDiff');
-
- return fetchDiffData(rootState, state.paths.diffEndpoint, REPORT_TYPE_SAST)
- .then((data) => {
- dispatch('receiveDiffSuccess', data);
- return data;
- })
- .catch(() => {
- dispatch('receiveDiffError');
- });
-};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js
deleted file mode 100644
index 1d5af1d4fe5..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import * as actions from './actions';
-import mutations from './mutations';
-import state from './state';
-
-export default {
- namespaced: true,
- state,
- mutations,
- actions,
-};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js
deleted file mode 100644
index aacec0fb679..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js
+++ /dev/null
@@ -1,4 +0,0 @@
-export const RECEIVE_DIFF_SUCCESS = 'RECEIVE_DIFF_SUCCESS';
-export const RECEIVE_DIFF_ERROR = 'RECEIVE_DIFF_ERROR';
-export const REQUEST_DIFF = 'REQUEST_DIFF';
-export const SET_DIFF_ENDPOINT = 'SET_DIFF_ENDPOINT';
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js
deleted file mode 100644
index 11aa71d2b6b..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import Vue from 'vue';
-import { parseDiff } from '../../utils';
-import * as types from './mutation_types';
-
-export default {
- [types.SET_DIFF_ENDPOINT](state, path) {
- Vue.set(state.paths, 'diffEndpoint', path);
- },
-
- [types.REQUEST_DIFF](state) {
- state.isLoading = true;
- },
-
- [types.RECEIVE_DIFF_SUCCESS](state, { diff, enrichData }) {
- const { added, fixed, existing } = parseDiff(diff, enrichData);
- const baseReportOutofDate = diff.base_report_out_of_date || false;
- const hasBaseReport = Boolean(diff.base_report_created_at);
-
- state.isLoading = false;
- state.newIssues = added;
- state.resolvedIssues = fixed;
- state.allIssues = existing;
- state.baseReportOutofDate = baseReportOutofDate;
- state.hasBaseReport = hasBaseReport;
- },
-
- [types.RECEIVE_DIFF_ERROR](state) {
- state.isLoading = false;
- state.hasError = true;
- },
-};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js
deleted file mode 100644
index c1b3f546431..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js
+++ /dev/null
@@ -1,14 +0,0 @@
-export default () => ({
- paths: {
- diffEndpoint: null,
- },
-
- isLoading: false,
- hasError: false,
-
- newIssues: [],
- resolvedIssues: [],
- allIssues: [],
- baseReportOutofDate: false,
- hasBaseReport: false,
-});
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js
deleted file mode 100644
index 13ca154bfa7..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { REPORT_TYPE_SECRET_DETECTION } from '~/vue_shared/security_reports/constants';
-import { fetchDiffData } from '../../utils';
-import * as types from './mutation_types';
-
-export const setDiffEndpoint = ({ commit }, path) => commit(types.SET_DIFF_ENDPOINT, path);
-
-export const requestDiff = ({ commit }) => commit(types.REQUEST_DIFF);
-
-export const receiveDiffSuccess = ({ commit }, response) =>
- commit(types.RECEIVE_DIFF_SUCCESS, response);
-
-export const receiveDiffError = ({ commit }, response) =>
- commit(types.RECEIVE_DIFF_ERROR, response);
-
-export const fetchDiff = ({ state, rootState, dispatch }) => {
- dispatch('requestDiff');
-
- return fetchDiffData(rootState, state.paths.diffEndpoint, REPORT_TYPE_SECRET_DETECTION)
- .then((data) => {
- dispatch('receiveDiffSuccess', data);
- return data;
- })
- .catch(() => {
- dispatch('receiveDiffError');
- });
-};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js
deleted file mode 100644
index 1d5af1d4fe5..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import * as actions from './actions';
-import mutations from './mutations';
-import state from './state';
-
-export default {
- namespaced: true,
- state,
- mutations,
- actions,
-};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js
deleted file mode 100644
index aacec0fb679..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js
+++ /dev/null
@@ -1,4 +0,0 @@
-export const RECEIVE_DIFF_SUCCESS = 'RECEIVE_DIFF_SUCCESS';
-export const RECEIVE_DIFF_ERROR = 'RECEIVE_DIFF_ERROR';
-export const REQUEST_DIFF = 'REQUEST_DIFF';
-export const SET_DIFF_ENDPOINT = 'SET_DIFF_ENDPOINT';
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js
deleted file mode 100644
index ee943b0621c..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { parseDiff } from '~/vue_shared/security_reports/store/utils';
-import * as types from './mutation_types';
-
-export default {
- [types.SET_DIFF_ENDPOINT](state, path) {
- state.paths.diffEndpoint = path;
- },
-
- [types.REQUEST_DIFF](state) {
- state.isLoading = true;
- },
-
- [types.RECEIVE_DIFF_SUCCESS](state, { diff, enrichData }) {
- const { added, fixed, existing } = parseDiff(diff, enrichData);
- const baseReportOutofDate = diff.base_report_out_of_date || false;
- const hasBaseReport = Boolean(diff.base_report_created_at);
-
- state.isLoading = false;
- state.newIssues = added;
- state.resolvedIssues = fixed;
- state.allIssues = existing;
- state.baseReportOutofDate = baseReportOutofDate;
- state.hasBaseReport = hasBaseReport;
- },
-
- [types.RECEIVE_DIFF_ERROR](state) {
- state.isLoading = false;
- state.hasError = true;
- },
-};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js
deleted file mode 100644
index c1b3f546431..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js
+++ /dev/null
@@ -1,14 +0,0 @@
-export default () => ({
- paths: {
- diffEndpoint: null,
- },
-
- isLoading: false,
- hasError: false,
-
- newIssues: [],
- resolvedIssues: [],
- allIssues: [],
- baseReportOutofDate: false,
- hasBaseReport: false,
-});
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/state.js b/app/assets/javascripts/vue_shared/security_reports/store/state.js
deleted file mode 100644
index 5dc4d1ad2fb..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/store/state.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import { MODULE_SAST, MODULE_SECRET_DETECTION } from './constants';
-
-export default () => ({
- reportTypes: [MODULE_SAST, MODULE_SECRET_DETECTION],
-});
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/utils.js b/app/assets/javascripts/vue_shared/security_reports/store/utils.js
deleted file mode 100644
index f620bad8dba..00000000000
--- a/app/assets/javascripts/vue_shared/security_reports/store/utils.js
+++ /dev/null
@@ -1,154 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-import pollUntilComplete from '~/lib/utils/poll_until_complete';
-import { __, n__, sprintf } from '~/locale';
-import { CRITICAL, HIGH } from '~/vulnerabilities/constants';
-import {
- FEEDBACK_TYPE_DISMISSAL,
- FEEDBACK_TYPE_ISSUE,
- FEEDBACK_TYPE_MERGE_REQUEST,
-} from '../constants';
-
-export const fetchDiffData = (state, endpoint, category) => {
- const requests = [pollUntilComplete(endpoint)];
-
- if (state.canReadVulnerabilityFeedback) {
- requests.push(axios.get(state.vulnerabilityFeedbackPath, { params: { category } }));
- }
-
- return Promise.all(requests).then(([diffResponse, enrichResponse]) => ({
- diff: diffResponse.data,
- enrichData: enrichResponse?.data ?? [],
- }));
-};
-
-/**
- * Returns given vulnerability enriched with the corresponding
- * feedback (`dismissal` or `issue` type)
- * @param {Object} vulnerabilityObject
- * @param {Array} feedbackList
- */
-export const enrichVulnerabilityWithFeedback = (vulnerabilityObject, feedbackList = []) => {
- const vulnerability = { ...vulnerabilityObject };
- // Some records may have a null `uuid`, we need to fallback to using `project_fingerprint` in those cases. Once all entries have been fixed, we can remove the fallback.
- // related epic: https://gitlab.com/groups/gitlab-org/-/epics/2791
- feedbackList
- .filter((fb) =>
- fb.finding_uuid
- ? fb.finding_uuid === vulnerability.uuid
- : fb.project_fingerprint === vulnerability.project_fingerprint,
- )
- .forEach((feedback) => {
- if (feedback.feedback_type === FEEDBACK_TYPE_DISMISSAL) {
- vulnerability.isDismissed = true;
- vulnerability.dismissalFeedback = feedback;
- } else if (feedback.feedback_type === FEEDBACK_TYPE_ISSUE && feedback.issue_iid) {
- vulnerability.hasIssue = true;
- vulnerability.issue_feedback = feedback;
- } else if (
- feedback.feedback_type === FEEDBACK_TYPE_MERGE_REQUEST &&
- feedback.merge_request_iid
- ) {
- vulnerability.hasMergeRequest = true;
- vulnerability.merge_request_feedback = feedback;
- }
- });
-
- return vulnerability;
-};
-
-/**
- * Generates the added, fixed, and existing vulnerabilities from the API report.
- *
- * @param {Object} diff The original reports.
- * @param {Object} enrichData Feedback data to add to the reports.
- * @returns {Object}
- */
-export const parseDiff = (diff, enrichData) => {
- const enrichVulnerability = (vulnerability) => ({
- ...enrichVulnerabilityWithFeedback(vulnerability, enrichData),
- category: vulnerability.report_type,
- title: vulnerability.message || vulnerability.name,
- });
-
- return {
- added: diff.added ? diff.added.map(enrichVulnerability) : [],
- fixed: diff.fixed ? diff.fixed.map(enrichVulnerability) : [],
- existing: diff.existing ? diff.existing.map(enrichVulnerability) : [],
- };
-};
-
-const createCountMessage = ({ critical, high, other, total }) => {
- const otherMessage = n__('%d Other', '%d Others', other);
- const countMessage = __(
- '%{criticalStart}%{critical} Critical%{criticalEnd} %{highStart}%{high} High%{highEnd} and %{otherStart}%{otherMessage}%{otherEnd}',
- );
- return total ? sprintf(countMessage, { critical, high, otherMessage }) : '';
-};
-
-const createStatusMessage = ({ reportType, status, total }) => {
- const vulnMessage = n__('vulnerability', 'vulnerabilities', total);
- let message;
- if (status) {
- message = __('%{reportType} %{status}');
- } else if (!total) {
- message = __('%{reportType} detected no new vulnerabilities.');
- } else {
- message = __(
- '%{reportType} detected %{totalStart}%{total}%{totalEnd} potential %{vulnMessage}',
- );
- }
- return sprintf(message, { reportType, status, total, vulnMessage });
-};
-
-/**
- * Counts vulnerabilities.
- * Returns the amount of critical, high, and other vulnerabilities.
- *
- * @param {Array} vulnerabilities The raw vulnerabilities to parse
- * @returns {{critical: number, high: number, other: number}}
- */
-export const countVulnerabilities = (vulnerabilities = []) =>
- vulnerabilities.reduce(
- (acc, { severity }) => {
- if (severity === CRITICAL) {
- acc.critical += 1;
- } else if (severity === HIGH) {
- acc.high += 1;
- } else {
- acc.other += 1;
- }
-
- return acc;
- },
- { critical: 0, high: 0, other: 0 },
- );
-
-/**
- * Takes an object of options and returns the object with an externalized string representing
- * the critical, high, and other severity vulnerabilities for a given report.
- *
- * The resulting string _may_ still contain sprintf-style placeholders. These
- * are left in place so they can be replaced with markup, via the
- * SecuritySummary component.
- * @param {{reportType: string, status: string, critical: number, high: number, other: number}} options
- * @returns {Object} the parameters with an externalized string
- */
-export const groupedTextBuilder = ({
- reportType = '',
- status = '',
- critical = 0,
- high = 0,
- other = 0,
-} = {}) => {
- const total = critical + high + other;
-
- return {
- countMessage: createCountMessage({ critical, high, other, total }),
- message: createStatusMessage({ reportType, status, total }),
- critical,
- high,
- other,
- status,
- total,
- };
-};
diff --git a/app/assets/javascripts/vue_shared/security_reports/utils.js b/app/assets/javascripts/vue_shared/security_reports/utils.js
index 0add91c402e..aa4f6978552 100644
--- a/app/assets/javascripts/vue_shared/security_reports/utils.js
+++ b/app/assets/javascripts/vue_shared/security_reports/utils.js
@@ -39,13 +39,3 @@ export const extractSecurityReportArtifacts = (reportTypes, jobs) => {
return acc;
}, []);
};
-
-export const extractSecurityReportArtifactsFromPipeline = (reportTypes, data) => {
- const jobs = data.project?.pipeline?.jobs?.nodes ?? [];
- return extractSecurityReportArtifacts(reportTypes, jobs);
-};
-
-export const extractSecurityReportArtifactsFromMergeRequest = (reportTypes, data) => {
- const jobs = data.project?.mergeRequest?.headPipeline?.jobs?.nodes ?? [];
- return extractSecurityReportArtifacts(reportTypes, jobs);
-};
diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue
index 8bb8b6101d4..9053d8972de 100644
--- a/app/assets/javascripts/work_items/components/item_state.vue
+++ b/app/assets/javascripts/work_items/components/item_state.vue
@@ -54,7 +54,7 @@ export default {
:label-for="$options.labelId"
label-cols="3"
label-cols-lg="2"
- label-class="gl-pb-0! gl-overflow-wrap-break"
+ label-class="gl-pb-0! gl-overflow-wrap-break work-item-field-label"
class="gl-align-items-center"
>
<gl-form-select
@@ -63,7 +63,7 @@ export default {
:options="$options.states"
:disabled="disabled"
data-testid="work-item-state-select"
- class="gl-w-auto hide-select-decoration gl-pl-4 gl-my-1"
+ class="gl-w-auto hide-select-decoration gl-pl-4 gl-my-1 work-item-field-value"
:class="{ 'gl-bg-transparent! gl-cursor-text!': disabled }"
@change="setState"
/>
diff --git a/app/assets/javascripts/work_items/components/notes/system_note.vue b/app/assets/javascripts/work_items/components/notes/system_note.vue
index 1fa217f456e..7903adea9bd 100644
--- a/app/assets/javascripts/work_items/components/notes/system_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/system_note.vue
@@ -114,7 +114,7 @@ export default {
:note-id="noteId"
:is-system-note="true"
>
- <span ref="gfm-content" v-safe-html="actionTextHtml"></span>
+ <span ref="gfm-content" v-safe-html="actionTextHtml" class="gl-word-break-word"></span>
<template v-if="canSeeDescriptionVersion" #extra-controls>
&middot;
<gl-button
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
index 7ad424868c6..a2667a379e1 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
@@ -18,10 +18,12 @@ import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutati
import updateWorkItemNoteMutation from '../../graphql/notes/update_work_item_note.mutation.graphql';
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import WorkItemCommentForm from './work_item_comment_form.vue';
+import WorkItemNoteAwardsList from './work_item_note_awards_list.vue';
export default {
name: 'WorkItemNoteThread',
components: {
+ WorkItemNoteAwardsList,
TimelineEntryItem,
NoteBody,
NoteHeader,
@@ -101,6 +103,9 @@ export default {
author() {
return this.note.author;
},
+ authorId() {
+ return getIdFromGraphQLId(this.author.id);
+ },
entryClass() {
return {
'note note-wrapper note-comment': true,
@@ -149,10 +154,10 @@ export default {
return window.gon.current_user_id;
},
isCurrentUserAuthorOfNote() {
- return getIdFromGraphQLId(this.author.id) === this.currentUserId;
+ return this.authorId === this.currentUserId;
},
isWorkItemAuthor() {
- return getIdFromGraphQLId(this.workItem?.author?.id) === getIdFromGraphQLId(this.author.id);
+ return getIdFromGraphQLId(this.workItem?.author?.id) === this.authorId;
},
projectName() {
return this.workItem?.project?.name;
@@ -284,7 +289,12 @@ export default {
<template>
<timeline-entry-item :id="noteAnchorId" :class="entryClass">
<div :key="note.id" class="timeline-avatar gl-float-left">
- <gl-avatar-link :href="author.webUrl">
+ <gl-avatar-link
+ :href="author.webUrl"
+ :data-user-id="authorId"
+ :data-username="author.username"
+ class="js-user-link"
+ >
<gl-avatar
:src="author.avatarUrl"
:entity-name="author.username"
@@ -323,6 +333,8 @@ export default {
<div class="gl-display-inline-flex">
<note-actions
:show-award-emoji="hasAwardEmojiPermission"
+ :work-item-iid="workItemIid"
+ :note="note"
:note-url="noteUrl"
:show-reply="showReply"
:show-edit="hasAdminPermission"
@@ -356,6 +368,9 @@ export default {
:class="isFirstNote ? 'gl-pl-3' : 'gl-pl-8'"
/>
</div>
+ <div class="note-awards" :class="isFirstNote ? '' : 'gl-pl-7'">
+ <work-item-note-awards-list :note="note" :work-item-iid="workItemIid" :is-modal="isModal" />
+ </div>
</div>
</timeline-entry-item>
</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
index b32a8c78c93..e5da3d346ae 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
@@ -1,17 +1,15 @@
<script>
import {
GlButton,
- GlIcon,
GlTooltipDirective,
GlDisclosureDropdown,
GlDisclosureDropdownItem,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
-import { __, s__, sprintf } from '~/locale';
+import { __, sprintf } from '~/locale';
import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import ReplyButton from '~/notes/components/note_actions/reply_button.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import addAwardEmojiMutation from '../../graphql/notes/work_item_note_add_award_emoji.mutation.graphql';
+import { getMutation, optimisticAwardUpdate } from '../../notes/award_utils';
export default {
name: 'WorkItemNoteActions',
@@ -25,19 +23,26 @@ export default {
reportAbuseText: __('Report abuse to administrator'),
},
components: {
+ EmojiPicker: () => import('~/emoji/components/picker.vue'),
GlButton,
- GlIcon,
GlDisclosureDropdown,
GlDisclosureDropdownItem,
ReplyButton,
- EmojiPicker: () => import('~/emoji/components/picker.vue'),
UserAccessRoleBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagsMixin()],
+ inject: ['fullPath'],
props: {
+ workItemIid: {
+ type: String,
+ required: true,
+ },
+ note: {
+ type: Object,
+ required: true,
+ },
showReply: {
type: Boolean,
required: true,
@@ -126,24 +131,29 @@ export default {
methods: {
async setAwardEmoji(name) {
+ const { mutation, mutationName, errorMessage } = getMutation({ note: this.note, name });
+
try {
- const {
- data: {
- awardEmojiAdd: { errors = [] },
- },
- } = await this.$apollo.mutate({
- mutation: addAwardEmojiMutation,
+ await this.$apollo.mutate({
+ mutation,
variables: {
- awardableId: this.noteId,
+ awardableId: this.note.id,
name,
},
+ optimisticResponse: {
+ [mutationName]: {
+ errors: [],
+ },
+ },
+ update: optimisticAwardUpdate({
+ note: this.note,
+ name,
+ fullPath: this.fullPath,
+ workItemIid: this.workItemIid,
+ }),
});
-
- if (errors.length > 0) {
- throw new Error(errors[0].message);
- }
} catch (error) {
- this.$emit('error', s__('WorkItem|Failed to award emoji'));
+ this.$emit('error', errorMessage);
Sentry.captureException(error);
}
},
@@ -185,23 +195,11 @@ export default {
{{ __('Contributor') }}
</user-access-role-badge>
<emoji-picker
- v-if="showAwardEmoji && glFeatures.workItemsMvc2"
+ v-if="showAwardEmoji"
toggle-class="note-action-button note-emoji-button btn-icon btn-default-tertiary"
data-testid="note-emoji-button"
@click="setAwardEmoji"
- >
- <template #button-content>
- <gl-icon class="award-control-icon-neutral gl-button-icon gl-icon" name="slight-smile" />
- <gl-icon
- class="award-control-icon-positive gl-button-icon gl-icon gl-left-3!"
- name="smiley"
- />
- <gl-icon
- class="award-control-icon-super-positive gl-button-icon gl-icon gl-left-3!"
- name="smile"
- />
- </template>
- </emoji-picker>
+ />
<reply-button v-if="showReply" ref="replyButton" @startReplying="$emit('startReplying')" />
<gl-button
v-if="showEdit"
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue
new file mode 100644
index 00000000000..3c30c204ab6
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue
@@ -0,0 +1,95 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import AwardsList from '~/vue_shared/components/awards_list.vue';
+import { getMutation, optimisticAwardUpdate } from '../../notes/award_utils';
+
+export default {
+ components: {
+ AwardsList,
+ },
+ inject: ['fullPath'],
+ props: {
+ workItemIid: {
+ type: String,
+ required: true,
+ },
+ note: {
+ type: Object,
+ required: true,
+ },
+ isModal: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ awardsListBoundary() {
+ return this.isModal ? '.modal-body' : '';
+ },
+ awards() {
+ return this.note.awardEmoji.nodes.map((award) => {
+ return {
+ ...award,
+ user: {
+ ...award.user,
+ id: getIdFromGraphQLId(award.user.id),
+ },
+ };
+ });
+ },
+ hasAwardEmojiPermission() {
+ return this.note.userPermissions.awardEmoji;
+ },
+ currentUserId() {
+ return window.gon.current_user_id;
+ },
+ },
+ methods: {
+ async handleAward(name) {
+ if (!this.hasAwardEmojiPermission) {
+ return;
+ }
+
+ const { mutation, mutationName, errorMessage } = getMutation({ note: this.note, name });
+
+ try {
+ await this.$apollo.mutate({
+ mutation,
+ variables: {
+ awardableId: this.note.id,
+ name,
+ },
+ optimisticResponse: {
+ [mutationName]: {
+ errors: [],
+ },
+ },
+ update: optimisticAwardUpdate({
+ note: this.note,
+ name,
+ fullPath: this.fullPath,
+ workItemIid: this.workItemIid,
+ }),
+ });
+ } catch (error) {
+ this.$emit('error', errorMessage);
+ Sentry.captureException(error);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <awards-list
+ v-if="awards.length"
+ :awards="awards"
+ :can-award-emoji="hasAwardEmojiPermission"
+ :current-user-id="currentUserId"
+ :boundary="awardsListBoundary"
+ class="gl-px-2"
+ @award="handleAward($event)"
+ />
+</template>
diff --git a/app/assets/javascripts/work_items/components/widget_wrapper.vue b/app/assets/javascripts/work_items/components/widget_wrapper.vue
index 6ae30e9b084..f343f787358 100644
--- a/app/assets/javascripts/work_items/components/widget_wrapper.vue
+++ b/app/assets/javascripts/work_items/components/widget_wrapper.vue
@@ -27,6 +27,9 @@ export default {
toggleLabel() {
return this.isOpen ? __('Collapse') : __('Expand');
},
+ isOpenString() {
+ return this.isOpen ? 'true' : 'false';
+ },
},
methods: {
hide() {
@@ -43,18 +46,10 @@ export default {
</script>
<template>
- <div
- id="tasks"
- class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-5"
- >
- <div
- class="gl-pl-5 gl-pr-4 gl-py-4 gl-display-flex gl-justify-content-space-between gl-bg-white gl-rounded-base"
- :class="{
- 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100 gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!': isOpen,
- }"
- >
- <div class="gl-display-flex gl-flex-grow-1">
- <h3 class="card-title h5 gl-m-0 gl-relative gl-line-height-24">
+ <div id="tasks" class="gl-new-card" :aria-expanded="isOpenString">
+ <div class="gl-new-card-header">
+ <div class="gl-new-card-title-wrapper">
+ <h3 class="gl-new-card-title">
<gl-link
id="user-content-tasks-links"
class="anchor position-absolute gl-text-decoration-none"
@@ -66,7 +61,7 @@ export default {
<slot name="header-suffix"></slot>
</div>
<slot name="header-right"></slot>
- <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3">
+ <div class="gl-new-card-toggle">
<gl-button
category="tertiary"
size="small"
@@ -80,12 +75,7 @@ export default {
<gl-alert v-if="error" variant="danger" @dismiss="$emit('dismissAlert')">
{{ error }}
</gl-alert>
- <div
- v-if="isOpen"
- class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
- :class="{ 'gl-p-3': !error }"
- data-testid="widget-body"
- >
+ <div v-if="isOpen" class="gl-new-card-body" :class="{ error: error }" data-testid="widget-body">
<slot name="body"></slot>
</div>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue
index d0d520ae5b1..f7ac63e16c3 100644
--- a/app/assets/javascripts/work_items/components/work_item_assignees.vue
+++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue
@@ -303,7 +303,7 @@ export default {
<div class="form-row gl-mb-5 work-item-assignees gl-relative gl-flex-nowrap">
<span
:id="assigneesTitleId"
- class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break"
+ class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break work-item-field-label"
data-testid="assignees-title"
>{{ assigneeText }}</span
>
@@ -313,11 +313,12 @@ export default {
:selected-tokens="localAssignees"
:container-class="containerClass"
:class="{ 'gl-hover-border-gray-200': canUpdate }"
+ menu-class="token-selector-menu-class"
:dropdown-items="dropdownItems"
:loading="isLoadingUsers && !isLoadingMore"
:view-only="!canUpdate"
:allow-clear-all="isEditing"
- class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2"
+ class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2 work-item-field-value"
data-testid="work-item-assignees-input"
@input="handleAssigneesInput"
@text-input="debouncedSearchKeyUpdate"
@@ -339,7 +340,7 @@ export default {
class="assign-myself"
data-testid="assign-self"
@click.stop="assignToCurrentUser"
- >{{ __('Assign myself') }}</gl-button
+ >{{ __('Assign yourself') }}</gl-button
>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue
new file mode 100644
index 00000000000..c727075eaac
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue
@@ -0,0 +1,180 @@
+<script>
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import {
+ sprintfWorkItem,
+ WIDGET_TYPE_ASSIGNEES,
+ WIDGET_TYPE_HEALTH_STATUS,
+ WIDGET_TYPE_ITERATION,
+ WIDGET_TYPE_LABELS,
+ WIDGET_TYPE_MILESTONE,
+ WIDGET_TYPE_PROGRESS,
+ WIDGET_TYPE_START_AND_DUE_DATE,
+ WIDGET_TYPE_WEIGHT,
+} from '../constants';
+import WorkItemState from './work_item_state.vue';
+import WorkItemDueDate from './work_item_due_date.vue';
+import WorkItemAssignees from './work_item_assignees.vue';
+import WorkItemLabels from './work_item_labels.vue';
+import WorkItemMilestone from './work_item_milestone.vue';
+
+export default {
+ components: {
+ WorkItemLabels,
+ WorkItemMilestone,
+ WorkItemAssignees,
+ WorkItemDueDate,
+ WorkItemState,
+ WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'),
+ WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'),
+ WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'),
+ WorkItemHealthStatus: () =>
+ import('ee_component/work_items/components/work_item_health_status.vue'),
+ },
+ mixins: [glFeatureFlagMixin()],
+ inject: ['fullPath'],
+ props: {
+ workItem: {
+ type: Object,
+ required: true,
+ },
+ workItemParentId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ workItemType() {
+ return this.workItem.workItemType?.name;
+ },
+ canUpdate() {
+ return this.workItem?.userPermissions?.updateWorkItem;
+ },
+ canDelete() {
+ return this.workItem?.userPermissions?.deleteWorkItem;
+ },
+ canSetWorkItemMetadata() {
+ return this.workItem?.userPermissions?.setWorkItemMetadata;
+ },
+ canAssignUnassignUser() {
+ return this.workItemAssignees && this.canSetWorkItemMetadata;
+ },
+ confidentialTooltip() {
+ return sprintfWorkItem(this.$options.i18n.confidentialTooltip, this.workItemType);
+ },
+ workItemAssignees() {
+ return this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES);
+ },
+ workItemLabels() {
+ return this.isWidgetPresent(WIDGET_TYPE_LABELS);
+ },
+ workItemDueDate() {
+ return this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE);
+ },
+ workItemWeight() {
+ return this.isWidgetPresent(WIDGET_TYPE_WEIGHT);
+ },
+ workItemProgress() {
+ return this.isWidgetPresent(WIDGET_TYPE_PROGRESS);
+ },
+ workItemIteration() {
+ return this.isWidgetPresent(WIDGET_TYPE_ITERATION);
+ },
+ workItemHealthStatus() {
+ return this.isWidgetPresent(WIDGET_TYPE_HEALTH_STATUS);
+ },
+ workItemMilestone() {
+ return this.isWidgetPresent(WIDGET_TYPE_MILESTONE);
+ },
+ },
+ methods: {
+ isWidgetPresent(type) {
+ return this.workItem?.widgets?.find((widget) => widget.type === type);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="work-item-attributes-wrapper">
+ <work-item-state
+ :work-item="workItem"
+ :work-item-parent-id="workItemParentId"
+ :can-update="canUpdate"
+ @error="$emit('error', $event)"
+ />
+ <work-item-assignees
+ v-if="workItemAssignees"
+ :can-update="canUpdate"
+ :work-item-id="workItem.id"
+ :assignees="workItemAssignees.assignees.nodes"
+ :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
+ :work-item-type="workItemType"
+ :can-invite-members="workItemAssignees.canInviteMembers"
+ @error="$emit('error', $event)"
+ />
+ <work-item-labels
+ v-if="workItemLabels"
+ :can-update="canUpdate"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
+ @error="$emit('error', $event)"
+ />
+ <work-item-due-date
+ v-if="workItemDueDate"
+ :can-update="canUpdate"
+ :due-date="workItemDueDate.dueDate"
+ :start-date="workItemDueDate.startDate"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
+ @error="$emit('error', $event)"
+ />
+ <work-item-milestone
+ v-if="workItemMilestone"
+ :work-item-id="workItem.id"
+ :work-item-milestone="workItemMilestone.milestone"
+ :work-item-type="workItemType"
+ :can-update="canUpdate"
+ @error="$emit('error', $event)"
+ />
+ <work-item-weight
+ v-if="workItemWeight"
+ class="gl-mb-5"
+ :can-update="canUpdate"
+ :weight="workItemWeight.weight"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
+ :work-item-type="workItemType"
+ @error="$emit('error', $event)"
+ />
+ <work-item-progress
+ v-if="workItemProgress"
+ class="gl-mb-5"
+ :can-update="canUpdate"
+ :progress="workItemProgress.progress"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
+ @error="$emit('error', $event)"
+ />
+ <work-item-iteration
+ v-if="workItemIteration"
+ class="gl-mb-5"
+ :iteration="workItemIteration.iteration"
+ :can-update="canUpdate"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
+ :work-item-type="workItemType"
+ @error="$emit('error', $event)"
+ />
+ <work-item-health-status
+ v-if="workItemHealthStatus"
+ class="gl-mb-5"
+ :health-status="workItemHealthStatus.healthStatus"
+ :can-update="canUpdate"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
+ :work-item-type="workItemType"
+ @error="$emit('error', $event)"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue
index 144c29b8ec3..3dd3a072d0f 100644
--- a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue
+++ b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue
@@ -7,9 +7,15 @@ import AwardsList from '~/vue_shared/components/awards_list.vue';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { TYPENAME_USER } from '~/graphql_shared/constants';
+import workItemAwardEmojiQuery from '../graphql/award_emoji.query.graphql';
import updateAwardEmojiMutation from '../graphql/update_award_emoji.mutation.graphql';
-import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
-import { EMOJI_THUMBSDOWN, EMOJI_THUMBSUP, WIDGET_TYPE_AWARD_EMOJI } from '../constants';
+import {
+ EMOJI_THUMBSDOWN,
+ EMOJI_THUMBSUP,
+ WIDGET_TYPE_AWARD_EMOJI,
+ DEFAULT_PAGE_SIZE_EMOJIS,
+ I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR,
+} from '../constants';
export default {
defaultAwards: [EMOJI_THUMBSUP, EMOJI_THUMBSDOWN],
@@ -26,16 +32,17 @@ export default {
type: String,
required: true,
},
- awardEmoji: {
- type: Object,
- required: true,
- },
workItemIid: {
type: String,
required: false,
default: null,
},
},
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
computed: {
currentUserId() {
return window.gon.current_user_id;
@@ -47,6 +54,10 @@ export default {
* Parse and convert award emoji list to a format that AwardsList can understand
*/
awards() {
+ if (!this.awardEmoji) {
+ return [];
+ }
+
return this.awardEmoji.nodes.map((emoji) => ({
name: emoji.name,
user: {
@@ -55,16 +66,56 @@ export default {
},
}));
},
+ pageInfo() {
+ return this.awardEmoji?.pageInfo;
+ },
+ hasNextPage() {
+ return this.pageInfo?.hasNextPage;
+ },
+ },
+ apollo: {
+ awardEmoji: {
+ query: workItemAwardEmojiQuery,
+ variables() {
+ return {
+ iid: this.workItemIid,
+ fullPath: this.workItemFullpath,
+ after: this.after,
+ pageSize: DEFAULT_PAGE_SIZE_EMOJIS,
+ };
+ },
+ update(data) {
+ const widgets = data.workspace?.workItems?.nodes[0].widgets;
+ return widgets?.find((widget) => widget.type === WIDGET_TYPE_AWARD_EMOJI).awardEmoji || {};
+ },
+ skip() {
+ return !this.workItemIid;
+ },
+ result() {
+ if (this.hasNextPage) {
+ this.fetchAwardEmojis();
+ } else {
+ this.isLoading = false;
+ }
+ },
+ error() {
+ this.$emit('error', I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR);
+ },
+ },
},
methods: {
- getAwards() {
- return this.awardEmoji.nodes.map((emoji) => ({
- name: emoji.name,
- user: {
- id: getIdFromGraphQLId(emoji.user.id),
- name: emoji.user.name,
- },
- }));
+ async fetchAwardEmojis() {
+ this.isLoading = true;
+ try {
+ await this.$apollo.queries.awardEmoji.fetchMore({
+ variables: {
+ pageSize: DEFAULT_PAGE_SIZE_EMOJIS,
+ after: this.pageInfo?.endCursor,
+ },
+ });
+ } catch (error) {
+ this.$emit('error', I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR);
+ }
},
isEmojiPresentForCurrentUser(name) {
return (
@@ -108,8 +159,12 @@ export default {
},
updateWorkItemAwardEmojiWidgetCache({ cache, name, toggledOn }) {
const query = {
- query: workItemByIidQuery,
- variables: { fullPath: this.workItemFullpath, iid: this.workItemIid },
+ query: workItemAwardEmojiQuery,
+ variables: {
+ fullPath: this.workItemFullpath,
+ iid: this.workItemIid,
+ pageSize: DEFAULT_PAGE_SIZE_EMOJIS,
+ },
};
const sourceData = cache.readQuery(query);
@@ -117,7 +172,6 @@ export default {
const newData = produce(sourceData, (draftState) => {
const { widgets } = draftState.workspace.workItems.nodes[0];
const widgetAwardEmoji = widgets.find((widget) => widget.type === WIDGET_TYPE_AWARD_EMOJI);
-
widgetAwardEmoji.awardEmoji.nodes = this.getAwardEmojiNodes(name, toggledOn);
});
@@ -175,7 +229,7 @@ export default {
</script>
<template>
- <div class="gl-mt-3">
+ <div v-if="!isLoading" class="gl-mt-3">
<awards-list
data-testid="work-item-award-list"
:awards="awards"
diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue
index 61dec21cae4..58bf524f450 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -9,7 +9,6 @@ import EditedAt from '~/issues/show/components/edited.vue';
import Tracking from '~/tracking';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import { autocompleteDataSources, markdownPreviewPath } from '../utils';
-import workItemDescriptionSubscription from '../graphql/work_item_description.subscription.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants';
@@ -37,8 +36,7 @@ export default {
required: true,
},
},
- markdownDocsPath: helpPagePath('user/project/quick_actions'),
- quickActionsDocsPath: helpPagePath('user/project/quick_actions'),
+ markdownDocsPath: helpPagePath('user/markdown'),
data() {
return {
workItem: {},
@@ -75,14 +73,6 @@ export default {
error() {
this.$emit('error', i18n.fetchError);
},
- subscribeToMore: {
- document: workItemDescriptionSubscription,
- variables() {
- return {
- issuableId: this.workItemId,
- };
- },
- },
},
},
computed: {
diff --git a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue
index 9a2cdc1c172..07e03eba1d1 100644
--- a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue
@@ -94,7 +94,7 @@ export default {
</script>
<template>
- <div class="gl-mb-5 gl-border-t gl-pt-5">
+ <div class="gl-mb-5">
<div class="gl-display-inline-flex gl-align-items-center gl-mb-3">
<label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label>
<gl-button
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 1ac40fe7dcb..1402b313cee 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -9,6 +9,7 @@ import {
GlButton,
GlTooltipDirective,
GlEmptyState,
+ GlIntersectionObserver,
} from '@gitlab/ui';
import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg?raw';
import { s__ } from '~/locale';
@@ -22,27 +23,17 @@ import {
sprintfWorkItem,
i18n,
WIDGET_TYPE_ASSIGNEES,
- WIDGET_TYPE_LABELS,
WIDGET_TYPE_NOTIFICATIONS,
WIDGET_TYPE_CURRENT_USER_TODOS,
WIDGET_TYPE_DESCRIPTION,
WIDGET_TYPE_AWARD_EMOJI,
- WIDGET_TYPE_START_AND_DUE_DATE,
- WIDGET_TYPE_WEIGHT,
- WIDGET_TYPE_PROGRESS,
WIDGET_TYPE_HIERARCHY,
- WIDGET_TYPE_MILESTONE,
- WIDGET_TYPE_ITERATION,
- WIDGET_TYPE_HEALTH_STATUS,
WORK_ITEM_TYPE_VALUE_ISSUE,
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
WIDGET_TYPE_NOTES,
} from '../constants';
-import workItemDatesSubscription from '../../graphql_shared/subscriptions/work_item_dates.subscription.graphql';
-import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
-import workItemAssigneesSubscription from '../graphql/work_item_assignees.subscription.graphql';
-import workItemMilestoneSubscription from '../graphql/work_item_milestone.subscription.graphql';
+import workItemUpdatedSubscription from '../graphql/work_item_updated.subscription.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql';
import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
@@ -51,17 +42,13 @@ import { findHierarchyWidgetChildren } from '../utils';
import WorkItemTree from './work_item_links/work_item_tree.vue';
import WorkItemActions from './work_item_actions.vue';
import WorkItemTodos from './work_item_todos.vue';
-import WorkItemState from './work_item_state.vue';
import WorkItemTitle from './work_item_title.vue';
+import WorkItemAttributesWrapper from './work_item_attributes_wrapper.vue';
import WorkItemCreatedUpdated from './work_item_created_updated.vue';
import WorkItemDescription from './work_item_description.vue';
-import WorkItemAwardEmoji from './work_item_award_emoji.vue';
-import WorkItemDueDate from './work_item_due_date.vue';
-import WorkItemAssignees from './work_item_assignees.vue';
-import WorkItemLabels from './work_item_labels.vue';
-import WorkItemMilestone from './work_item_milestone.vue';
import WorkItemNotes from './work_item_notes.vue';
import WorkItemDetailModal from './work_item_detail_modal.vue';
+import WorkItemAwardEmoji from './work_item_award_emoji.vue';
export default {
i18n,
@@ -77,27 +64,19 @@ export default {
GlSkeletonLoader,
GlIcon,
GlEmptyState,
- WorkItemAssignees,
WorkItemActions,
WorkItemTodos,
WorkItemCreatedUpdated,
WorkItemDescription,
WorkItemAwardEmoji,
- WorkItemDueDate,
- WorkItemLabels,
WorkItemTitle,
- WorkItemState,
- WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'),
- WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'),
+ WorkItemAttributesWrapper,
WorkItemTypeIcon,
- WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'),
- WorkItemHealthStatus: () =>
- import('ee_component/work_items/components/work_item_health_status.vue'),
- WorkItemMilestone,
WorkItemTree,
WorkItemNotes,
WorkItemDetailModal,
AbuseCategorySelector,
+ GlIntersectionObserver,
},
mixins: [glFeatureFlagMixin()],
inject: ['fullPath', 'reportAbusePath'],
@@ -129,6 +108,7 @@ export default {
isReportDrawerOpen: false,
reportedUrl: '',
reportedUserId: 0,
+ isStickyHeaderShowing: false,
};
},
apollo: {
@@ -165,52 +145,17 @@ export default {
document.title = `${this.workItem.title} · ${this.workItem?.workItemType?.name}${path}`;
}
},
- subscribeToMore: [
- {
- document: workItemTitleSubscription,
- variables() {
- return {
- issuableId: this.workItem.id,
- };
- },
- skip() {
- return !this.workItem?.id;
- },
- },
- {
- document: workItemDatesSubscription,
- variables() {
- return {
- issuableId: this.workItem.id,
- };
- },
- skip() {
- return !this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE) || !this.workItem?.id;
- },
- },
- {
- document: workItemAssigneesSubscription,
- variables() {
- return {
- issuableId: this.workItem.id,
- };
- },
- skip() {
- return !this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES) || !this.workItem?.id;
- },
+ subscribeToMore: {
+ document: workItemUpdatedSubscription,
+ variables() {
+ return {
+ id: this.workItem.id,
+ };
},
- {
- document: workItemMilestoneSubscription,
- variables() {
- return {
- issuableId: this.workItem.id,
- };
- },
- skip() {
- return !this.isWidgetPresent(WIDGET_TYPE_MILESTONE) || !this.workItem?.id;
- },
+ skip() {
+ return !this.workItem?.id;
},
- ],
+ },
},
},
computed: {
@@ -289,38 +234,17 @@ export default {
return this.$options.isLoggedIn && this.workItemCurrentUserTodos;
},
currentUserTodos() {
- return this.workItemCurrentUserTodos?.currentUserTodos?.edges;
+ return this.workItemCurrentUserTodos?.currentUserTodos?.nodes;
},
workItemAssignees() {
return this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES);
},
- workItemLabels() {
- return this.isWidgetPresent(WIDGET_TYPE_LABELS);
- },
- workItemDueDate() {
- return this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE);
- },
- workItemWeight() {
- return this.isWidgetPresent(WIDGET_TYPE_WEIGHT);
- },
- workItemProgress() {
- return this.isWidgetPresent(WIDGET_TYPE_PROGRESS);
- },
workItemAwardEmoji() {
return this.isWidgetPresent(WIDGET_TYPE_AWARD_EMOJI);
},
workItemHierarchy() {
return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY);
},
- workItemIteration() {
- return this.isWidgetPresent(WIDGET_TYPE_ITERATION);
- },
- workItemHealthStatus() {
- return this.isWidgetPresent(WIDGET_TYPE_HEALTH_STATUS);
- },
- workItemMilestone() {
- return this.isWidgetPresent(WIDGET_TYPE_MILESTONE);
- },
workItemNotes() {
return this.isWidgetPresent(WIDGET_TYPE_NOTES);
},
@@ -332,6 +256,9 @@ export default {
'gl-pt-5': !this.updateError && !this.isModal,
};
},
+ showIntersectionObserver() {
+ return !this.isModal && this.workItemsMvc2Enabled;
+ },
},
mounted() {
if (this.modalWorkItemIid) {
@@ -437,6 +364,15 @@ export default {
this.reportedUrl = reply.url || {};
this.reportedUserId = reply.author ? getIdFromGraphQLId(reply.author.id) : 0;
},
+ hideStickyHeader() {
+ this.isStickyHeaderShowing = false;
+ },
+ showStickyHeader() {
+ // only if scrolled under the work item's title
+ if (this.$refs?.title?.$el.offsetTop < window.pageYOffset) {
+ this.isStickyHeaderShowing = true;
+ }
+ },
},
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
@@ -510,7 +446,9 @@ export default {
>
<work-item-todos
v-if="showWorkItemCurrentUserTodos"
- :work-item="workItem"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItemIid"
+ :work-item-fullpath="workItem.project.fullPath"
:current-user-todos="currentUserTodos"
@error="updateError = $event"
/>
@@ -539,141 +477,148 @@ export default {
@click="$emit('close')"
/>
</div>
- <work-item-title
- v-if="workItem.title"
- :work-item-id="workItem.id"
- :work-item-title="workItem.title"
- :work-item-type="workItemType"
- :work-item-parent-id="workItemParentId"
- :can-update="canUpdate"
- @error="updateError = $event"
- />
- <work-item-created-updated :work-item-iid="workItemIid" />
- <work-item-state
- :work-item="workItem"
- :work-item-parent-id="workItemParentId"
- :can-update="canUpdate"
- @error="updateError = $event"
- />
- <work-item-assignees
- v-if="workItemAssignees"
- :can-update="canUpdate"
- :work-item-id="workItem.id"
- :assignees="workItemAssignees.assignees.nodes"
- :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
- :work-item-type="workItemType"
- :can-invite-members="workItemAssignees.canInviteMembers"
- @error="updateError = $event"
- />
- <work-item-labels
- v-if="workItemLabels"
- :can-update="canUpdate"
- :work-item-id="workItem.id"
- :work-item-iid="workItem.iid"
- @error="updateError = $event"
- />
- <work-item-due-date
- v-if="workItemDueDate"
- :can-update="canUpdate"
- :due-date="workItemDueDate.dueDate"
- :start-date="workItemDueDate.startDate"
- :work-item-id="workItem.id"
- :work-item-type="workItemType"
- @error="updateError = $event"
- />
- <work-item-milestone
- v-if="workItemMilestone"
- :work-item-id="workItem.id"
- :work-item-milestone="workItemMilestone.milestone"
- :work-item-type="workItemType"
- :can-update="canUpdate"
- @error="updateError = $event"
- />
- <work-item-weight
- v-if="workItemWeight"
- class="gl-mb-5"
- :can-update="canUpdate"
- :weight="workItemWeight.weight"
- :work-item-id="workItem.id"
- :work-item-iid="workItem.iid"
- :work-item-type="workItemType"
- @error="updateError = $event"
- />
- <work-item-progress
- v-if="workItemProgress"
- class="gl-mb-5"
- :can-update="canUpdate"
- :progress="workItemProgress.progress"
- :work-item-id="workItem.id"
- :work-item-type="workItemType"
- @error="updateError = $event"
- />
- <work-item-iteration
- v-if="workItemIteration"
- class="gl-mb-5"
- :iteration="workItemIteration.iteration"
- :can-update="canUpdate"
- :work-item-id="workItem.id"
- :work-item-iid="workItem.iid"
- :work-item-type="workItemType"
- @error="updateError = $event"
- />
- <work-item-health-status
- v-if="workItemHealthStatus"
- class="gl-mb-5"
- :health-status="workItemHealthStatus.healthStatus"
- :can-update="canUpdate"
- :work-item-id="workItem.id"
- :work-item-iid="workItem.iid"
- :work-item-type="workItemType"
- @error="updateError = $event"
- />
- <work-item-description
- v-if="hasDescriptionWidget"
- :work-item-id="workItem.id"
- :work-item-iid="workItem.iid"
- class="gl-pt-5"
- @error="updateError = $event"
- />
- <work-item-award-emoji
- v-if="workItemAwardEmoji"
- :work-item-id="workItem.id"
- :work-item-fullpath="workItem.project.fullPath"
- :award-emoji="workItemAwardEmoji.awardEmoji"
- :work-item-iid="workItemIid"
- @error="updateError = $event"
- />
- <work-item-tree
- v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE"
- :work-item-type="workItemType"
- :parent-work-item-type="workItem.workItemType.name"
- :work-item-id="workItem.id"
- :work-item-iid="workItemIid"
- :children="children"
- :can-update="canUpdate"
- :confidential="workItem.confidential"
- @show-modal="openInModal"
- />
- <work-item-notes
- v-if="workItemNotes"
- :work-item-id="workItem.id"
- :work-item-iid="workItem.iid"
- :work-item-type="workItemType"
- :is-modal="isModal"
- :assignees="workItemAssignees && workItemAssignees.assignees.nodes"
- :can-set-work-item-metadata="canAssignUnassignUser"
- :report-abuse-path="reportAbusePath"
- class="gl-pt-5"
- @error="updateError = $event"
- @has-notes="updateHasNotes"
- @openReportAbuse="openReportAbuseDrawer"
- />
- <gl-empty-state
- v-if="error"
- :title="$options.i18n.fetchErrorTitle"
- :description="error"
- :svg-path="noAccessSvgPath"
- />
+ <div>
+ <work-item-title
+ v-if="workItem.title"
+ ref="title"
+ :work-item-id="workItem.id"
+ :work-item-title="workItem.title"
+ :work-item-type="workItemType"
+ :work-item-parent-id="workItemParentId"
+ :can-update="canUpdate"
+ @error="updateError = $event"
+ />
+ <work-item-created-updated :work-item-iid="workItemIid" />
+ </div>
+ <gl-intersection-observer
+ v-if="showIntersectionObserver"
+ @appear="hideStickyHeader"
+ @disappear="showStickyHeader"
+ >
+ <transition name="issuable-header-slide">
+ <div
+ v-if="isStickyHeaderShowing"
+ class="issue-sticky-header gl-fixed gl-bg-white gl-border-b gl-z-index-3 gl-py-2"
+ data-testid="work-item-sticky-header"
+ >
+ <div
+ class="gl-align-items-center gl-mx-auto gl-px-5 gl-display-flex gl-max-w-container-xl"
+ >
+ <span class="gl-text-truncate gl-font-weight-bold gl-pr-3 gl-mr-auto">
+ {{ workItem.title }}
+ </span>
+ <gl-loading-icon v-if="updateInProgress" class="gl-mr-3" />
+ <gl-badge
+ v-if="workItem.confidential"
+ v-gl-tooltip.bottom
+ :title="confidentialTooltip"
+ variant="warning"
+ icon="eye-slash"
+ class="gl-mr-3 gl-cursor-help"
+ >{{ __('Confidential') }}</gl-badge
+ >
+ <work-item-todos
+ v-if="showWorkItemCurrentUserTodos"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItemIid"
+ :work-item-fullpath="workItem.project.fullPath"
+ :current-user-todos="currentUserTodos"
+ @error="updateError = $event"
+ />
+ <work-item-actions
+ :work-item-id="workItem.id"
+ :subscribed-to-notifications="workItemNotificationsSubscribed"
+ :work-item-type="workItemType"
+ :work-item-type-id="workItemTypeId"
+ :can-delete="canDelete"
+ :can-update="canUpdate"
+ :is-confidential="workItem.confidential"
+ :is-parent-confidential="parentWorkItemConfidentiality"
+ :work-item-reference="workItem.reference"
+ :work-item-create-note-email="workItem.createNoteEmail"
+ :is-modal="isModal"
+ @deleteWorkItem="
+ $emit('deleteWorkItem', { workItemType, workItemId: workItem.id })
+ "
+ @toggleWorkItemConfidentiality="toggleConfidentiality"
+ @error="updateError = $event"
+ />
+ </div>
+ </div>
+ </transition>
+ </gl-intersection-observer>
+ <div
+ data-testid="work-item-overview"
+ :class="{ 'work-item-overview': workItemsMvc2Enabled }"
+ >
+ <section>
+ <work-item-attributes-wrapper
+ :class="{ 'gl-md-display-none!': workItemsMvc2Enabled }"
+ class="gl-border-b"
+ :work-item="workItem"
+ :work-item-parent-id="workItemParentId"
+ @error="updateError = $event"
+ />
+ <work-item-description
+ v-if="hasDescriptionWidget"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
+ class="gl-pt-5"
+ @error="updateError = $event"
+ />
+ <work-item-award-emoji
+ v-if="workItemAwardEmoji"
+ :work-item-id="workItem.id"
+ :work-item-fullpath="workItem.project.fullPath"
+ :award-emoji="workItemAwardEmoji.awardEmoji"
+ :work-item-iid="workItemIid"
+ @error="updateError = $event"
+ />
+ <work-item-tree
+ v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE"
+ :work-item-type="workItemType"
+ :parent-work-item-type="workItem.workItemType.name"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItemIid"
+ :children="children"
+ :can-update="canUpdate"
+ :confidential="workItem.confidential"
+ @show-modal="openInModal"
+ />
+ <work-item-notes
+ v-if="workItemNotes"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
+ :work-item-type="workItemType"
+ :is-modal="isModal"
+ :assignees="workItemAssignees && workItemAssignees.assignees.nodes"
+ :can-set-work-item-metadata="canAssignUnassignUser"
+ :report-abuse-path="reportAbusePath"
+ class="gl-pt-5"
+ @error="updateError = $event"
+ @has-notes="updateHasNotes"
+ @openReportAbuse="openReportAbuseDrawer"
+ />
+ <gl-empty-state
+ v-if="error"
+ :title="$options.i18n.fetchErrorTitle"
+ :description="error"
+ :svg-path="noAccessSvgPath"
+ />
+ </section>
+ <aside
+ v-if="workItemsMvc2Enabled"
+ data-testid="work-item-overview-right-sidebar"
+ class="work-item-overview-right-sidebar gl-display-none gl-md-display-block"
+ :class="{ 'is-modal': isModal }"
+ >
+ <work-item-attributes-wrapper
+ :work-item="workItem"
+ :work-item-parent-id="workItemParentId"
+ @error="updateError = $event"
+ />
+ </aside>
+ </div>
</template>
<work-item-detail-modal
v-if="!isModal"
diff --git a/app/assets/javascripts/work_items/components/work_item_due_date.vue b/app/assets/javascripts/work_items/components/work_item_due_date.vue
index 3e546598dc2..b4b3049d669 100644
--- a/app/assets/javascripts/work_items/components/work_item_due_date.vue
+++ b/app/assets/javascripts/work_items/components/work_item_due_date.vue
@@ -88,7 +88,11 @@ export default {
return !this.canUpdate && !this.dueDate && !this.startDate;
},
labelClass() {
- return this.isReadonlyWithNoDates ? 'gl-align-self-center gl-pb-0!' : 'gl-mt-3 gl-pb-0!';
+ return {
+ 'work-item-field-label': true,
+ 'gl-align-self-center gl-pb-0!': this.isReadonlyWithNoDates,
+ 'gl-mt-3 gl-pb-0!': !this.isReadonlyWithNoDates,
+ };
},
showDueDateButton() {
return this.canUpdate && !this.showDueDateInput;
diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue
index 015c86ba043..8676456a6a4 100644
--- a/app/assets/javascripts/work_items/components/work_item_labels.vue
+++ b/app/assets/javascripts/work_items/components/work_item_labels.vue
@@ -7,7 +7,6 @@ import labelSearchQuery from '~/sidebar/components/labels/labels_select_widget/g
import LabelItem from '~/sidebar/components/labels/labels_select_widget/label_item.vue';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
-import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
@@ -87,14 +86,6 @@ export default {
error() {
this.$emit('error', i18n.fetchError);
},
- subscribeToMore: {
- document: workItemLabelsSubscription,
- variables() {
- return {
- issuableId: this.workItemId,
- };
- },
- },
},
searchLabels: {
query: labelSearchQuery,
@@ -268,7 +259,7 @@ export default {
<div class="form-row gl-mb-5 work-item-labels gl-relative gl-flex-nowrap">
<span
:id="labelsTitleId"
- class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break"
+ class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break work-item-field-label"
data-testid="labels-title"
>{{ __('Labels') }}</span
>
@@ -281,7 +272,8 @@ export default {
:loading="isLoading"
:view-only="!canUpdate"
:allow-clear-all="isEditing"
- class="gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!"
+ class="gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2! work-item-field-value"
+ menu-class="token-selector-menu-class"
data-testid="work-item-labels-input"
:class="{ 'gl-hover-border-gray-200': canUpdate }"
@input="focusTokenSelector"
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 401c8a53eb0..ec44a654e89 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
@@ -271,7 +271,7 @@ export default {
@click="toggleItem"
/>
<div
- class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-rounded-base"
+ class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-mx-n2 gl-rounded-base"
data-testid="links-child"
>
<div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0">
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 b9fc92304c0..bfc6ceefccc 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
@@ -113,7 +113,7 @@ export default {
return this.parentIssue?.milestone;
},
children() {
- return this.workItem ? findHierarchyWidgetChildren(this.workItem) : [];
+ return findHierarchyWidgetChildren(this.workItem);
},
canUpdate() {
return this.workItem?.userPermissions.updateWorkItem || false;
@@ -205,10 +205,7 @@ export default {
>
<template #header>{{ $options.i18n.title }}</template>
<template #header-suffix>
- <span
- class="gl-display-inline-flex gl-align-items-center gl-line-height-24 gl-ml-3 gl-font-weight-bold gl-text-gray-500"
- data-testid="children-count"
- >
+ <span class="gl-new-card-count" data-testid="children-count">
<gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2" />
{{ childrenCountLabel }}
</span>
@@ -236,52 +233,53 @@ export default {
</gl-dropdown>
</template>
<template #body>
- <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-2" />
-
- <template v-else>
- <div v-if="isChildrenEmpty && !isShownAddForm && !error" data-testid="links-empty">
- <p class="gl-px-3 gl-py-2 gl-mb-0 gl-text-gray-500">
- {{ $options.i18n.emptyStateMessage }}
- </p>
- </div>
- <work-item-links-form
- v-if="isShownAddForm"
- ref="wiLinksForm"
- data-testid="add-links-form"
- :issuable-gid="issuableGid"
- :work-item-iid="iid"
- :children-ids="childrenIds"
- :parent-confidential="confidential"
- :parent-iteration="issuableIteration"
- :parent-milestone="issuableMilestone"
- :form-type="formType"
- :parent-work-item-type="workItem.workItemType.name"
- @cancel="hideAddForm"
- />
- <work-item-children-wrapper
- :children="children"
- :can-update="canUpdate"
- :work-item-id="issuableGid"
- :work-item-iid="iid"
- @error="error = $event"
- @show-modal="openChild"
- />
- <work-item-detail-modal
- ref="modal"
- :work-item-id="activeChild.id"
- :work-item-iid="activeChild.iid"
- @close="closeModal"
- @workItemDeleted="handleWorkItemDeleted(activeChild)"
- @openReportAbuse="openReportAbuseDrawer"
- />
- <abuse-category-selector
- v-if="isReportDrawerOpen && reportAbusePath"
- :reported-user-id="reportedUserId"
- :reported-from-url="reportedUrl"
- :show-drawer="isReportDrawerOpen"
- @close-drawer="toggleReportAbuseDrawer(false)"
- />
- </template>
+ <div class="gl-new-card-content">
+ <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-2" />
+ <template v-else>
+ <div v-if="isChildrenEmpty && !isShownAddForm && !error" data-testid="links-empty">
+ <p class="gl-new-card-empty">
+ {{ $options.i18n.emptyStateMessage }}
+ </p>
+ </div>
+ <work-item-links-form
+ v-if="isShownAddForm"
+ ref="wiLinksForm"
+ data-testid="add-links-form"
+ :issuable-gid="issuableGid"
+ :work-item-iid="iid"
+ :children-ids="childrenIds"
+ :parent-confidential="confidential"
+ :parent-iteration="issuableIteration"
+ :parent-milestone="issuableMilestone"
+ :form-type="formType"
+ :parent-work-item-type="workItem.workItemType.name"
+ @cancel="hideAddForm"
+ />
+ <work-item-children-wrapper
+ :children="children"
+ :can-update="canUpdate"
+ :work-item-id="issuableGid"
+ :work-item-iid="iid"
+ @error="error = $event"
+ @show-modal="openChild"
+ />
+ <work-item-detail-modal
+ ref="modal"
+ :work-item-id="activeChild.id"
+ :work-item-iid="activeChild.iid"
+ @close="closeModal"
+ @workItemDeleted="handleWorkItemDeleted(activeChild)"
+ @openReportAbuse="openReportAbuseDrawer"
+ />
+ <abuse-category-selector
+ v-if="isReportDrawerOpen && reportAbusePath"
+ :reported-user-id="reportedUserId"
+ :reported-from-url="reportedUrl"
+ :show-drawer="isReportDrawerOpen"
+ @close-drawer="toggleReportAbuseDrawer(false)"
+ />
+ </template>
+ </div>
</template>
</widget-wrapper>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
index 289a48b5eaf..db649913602 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
@@ -347,7 +347,8 @@ export default {
<template>
<gl-form
- class="gl-bg-white gl-mt-1 gl-mb-3 gl-p-4 gl-border gl-border-gray-100 gl-rounded-base"
+ class="gl-new-card-add-form"
+ data-testid="add-item-form"
@submit.prevent="addOrCreateMethod"
>
<gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError">
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
index 44e8dac79c4..83f3c391769 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
@@ -127,9 +127,11 @@ export default {
</template>
<template #body>
<div v-if="!isShownAddForm && children.length === 0" data-testid="tree-empty">
- <p class="gl-mb-0 gl-py-2 gl-ml-3 gl-text-gray-500">
- {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }}
- </p>
+ <div class="gl-new-card-content">
+ <p class="gl-new-card-empty">
+ {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }}
+ </p>
+ </div>
</div>
<work-item-links-form
v-if="isShownAddForm"
diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone.vue
index 693397686d0..6cc61ed4756 100644
--- a/app/assets/javascripts/work_items/components/work_item_milestone.vue
+++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue
@@ -208,13 +208,13 @@ export default {
class="work-item-dropdown gl-flex-nowrap"
:label="$options.i18n.MILESTONE"
label-for="milestone-value"
- label-class="gl-pb-0! gl-mt-3 gl-overflow-wrap-break"
+ label-class="gl-pb-0! gl-mt-3 gl-overflow-wrap-break work-item-field-label"
label-cols="3"
label-cols-lg="2"
>
<span
v-if="!canUpdate"
- class="gl-text-secondary gl-ml-4 gl-mt-3 gl-display-inline-block gl-line-height-normal"
+ class="gl-text-secondary gl-ml-4 gl-mt-3 gl-display-inline-block gl-line-height-normal work-item-field-value"
data-testid="disabled-text"
>
{{ dropdownText }}
@@ -223,7 +223,7 @@ export default {
v-else
id="milestone-value"
data-testid="work-item-milestone-dropdown"
- class="gl-pl-0 gl-max-w-full"
+ class="gl-pl-0 gl-max-w-full work-item-field-value"
:toggle-class="dropdownClasses"
:text="dropdownText"
:loading="updateInProgress"
diff --git a/app/assets/javascripts/work_items/components/work_item_todos.vue b/app/assets/javascripts/work_items/components/work_item_todos.vue
index 4e787720a42..b21abf21be5 100644
--- a/app/assets/javascripts/work_items/components/work_item_todos.vue
+++ b/app/assets/javascripts/work_items/components/work_item_todos.vue
@@ -1,10 +1,20 @@
<script>
import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { produce } from 'immer';
+
import { s__ } from '~/locale';
import { updateGlobalTodoCount } from '~/sidebar/utils';
-import { getWorkItemTodoOptimisticResponse } from '../utils';
-import { ADD, MARK_AS_DONE, TODO_ADD_ICON, TODO_DONE_ICON } from '../constants';
-import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
+import createWorkItemTodosMutation from '~/work_items/graphql/create_work_item_todos.mutation.graphql';
+import markDoneWorkItemTodosMutation from '~/work_items/graphql/mark_done_work_item_todos.mutation.graphql';
+
+import {
+ TODO_ADD_ICON,
+ TODO_DONE_ICON,
+ TODO_PENDING_STATE,
+ TODO_DONE_STATE,
+ WIDGET_TYPE_CURRENT_USER_TODOS,
+} from '../constants';
export default {
i18n: {
@@ -19,8 +29,16 @@ export default {
GlButton,
},
props: {
- workItem: {
- type: Object,
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ workItemIid: {
+ type: String,
+ required: true,
+ },
+ workItemFullpath: {
+ type: String,
required: true,
},
currentUserTodos: {
@@ -39,8 +57,11 @@ export default {
};
},
computed: {
+ todoId() {
+ return this.currentUserTodos[0]?.id || '';
+ },
pendingTodo() {
- return this.currentUserTodos.length > 0;
+ return this.todoId !== '';
},
buttonIcon() {
return this.pendingTodo ? TODO_DONE_ICON : TODO_ADD_ICON;
@@ -50,28 +71,60 @@ export default {
onToggle() {
this.isLoading = true;
this.buttonLabel = '';
- const action = this.pendingTodo ? MARK_AS_DONE : ADD;
- const inputVariables = {
- id: this.workItem.id,
- currentUserTodosWidget: {
- action,
- },
+ let mutation = createWorkItemTodosMutation;
+ let inputVariables = {
+ targetId: this.workItemId,
};
+ if (this.pendingTodo) {
+ mutation = markDoneWorkItemTodosMutation;
+ inputVariables = {
+ id: this.todoId,
+ };
+ }
+
this.$apollo
.mutate({
- mutation: updateWorkItemMutation,
+ mutation,
variables: {
input: inputVariables,
},
- optimisticResponse: getWorkItemTodoOptimisticResponse({
- workItem: this.workItem,
- pendingTodo: this.pendingTodo,
- }),
+ optimisticResponse: {
+ todoMutation: {
+ todo: {
+ id: this.todoId,
+ state: this.pendingTodo ? TODO_DONE_STATE : TODO_PENDING_STATE,
+ },
+ errors: [],
+ },
+ },
+ update: (
+ cache,
+ {
+ data: {
+ todoMutation: { todo = {} },
+ },
+ },
+ ) => {
+ const todos = [];
+
+ if (todo.state === TODO_PENDING_STATE) {
+ todos.push({
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Todo',
+ id: todo.id,
+ });
+ }
+
+ this.updateWorkItemCurrentTodosWidgetCache({
+ cache,
+ todos,
+ });
+ },
})
.then(
({
data: {
- workItemUpdate: { errors },
+ todoMutation: { errors },
},
}) => {
if (errors?.length) {
@@ -93,6 +146,26 @@ export default {
this.isLoading = false;
});
},
+ updateWorkItemCurrentTodosWidgetCache({ cache, todos }) {
+ const query = {
+ query: workItemByIidQuery,
+ variables: { fullPath: this.workItemFullpath, iid: this.workItemIid },
+ };
+
+ const sourceData = cache.readQuery(query);
+
+ const newData = produce(sourceData, (draftState) => {
+ const { widgets } = draftState.workspace.workItems.nodes[0];
+
+ const widgetCurrentUserTodos = widgets.find(
+ (widget) => widget.type === WIDGET_TYPE_CURRENT_USER_TODOS,
+ );
+
+ widgetCurrentUserTodos.currentUserTodos.nodes = todos;
+ });
+
+ cache.writeQuery({ ...query, data: newData });
+ },
},
};
</script>
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index f3beaebf403..b8324d7d552 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -79,6 +79,10 @@ export const I18N_WORK_ITEM_FETCH_ITERATIONS_ERROR = s__(
'WorkItem|Something went wrong when fetching iterations. Please try again.',
);
+export const I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR = s__(
+ 'WorkItem|Something went wrong while fetching work item award emojis. Please try again.',
+);
+
export const I18N_WORK_ITEM_CREATE_BUTTON_LABEL = s__('WorkItem|Create %{workItemType}');
export const I18N_WORK_ITEM_ADD_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}');
export const I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}s');
@@ -192,6 +196,7 @@ export const FORM_TYPES = {
export const DEFAULT_PAGE_SIZE_ASSIGNEES = 10;
export const DEFAULT_PAGE_SIZE_NOTES = 30;
+export const DEFAULT_PAGE_SIZE_EMOJIS = 100;
export const WORK_ITEM_NOTES_SORT_ORDER_KEY = 'sort_direction_work_item';
@@ -231,16 +236,12 @@ export const TEST_ID_PROMOTE_ACTION = 'promote-action';
export const TEST_ID_COPY_REFERENCE_ACTION = 'copy-reference-action';
export const TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION = 'copy-create-note-email-action';
-export const ADD = 'ADD';
-export const MARK_AS_DONE = 'MARK_AS_DONE';
export const TODO_ADD_ICON = 'todo-add';
export const TODO_DONE_ICON = 'todo-done';
-export const TODO_TYPENAME = 'Todo';
-export const TODO_EDGE_TYPENAME = 'TodoEdge';
-export const TODO_CONNECTION_TYPENAME = 'TodoConnection';
+export const TODO_DONE_STATE = 'done';
+export const TODO_PENDING_STATE = 'pending';
+
export const CURRENT_USER_TODOS_TYPENAME = 'WorkItemWidgetCurrentUserTodos';
-export const WORK_ITEM_TYPENAME = 'WorkItem';
-export const WORK_ITEM_UPDATE_PAYLOAD_TYPENAME = 'WorkItemUpdatePayload';
export const EMOJI_ACTION_ADD = 'ADD';
export const EMOJI_ACTION_REMOVE = 'REMOVE';
diff --git a/app/assets/javascripts/work_items/graphql/award_emoji.query.graphql b/app/assets/javascripts/work_items/graphql/award_emoji.query.graphql
new file mode 100644
index 00000000000..82a532e1bea
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/award_emoji.query.graphql
@@ -0,0 +1,27 @@
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+#import "~/work_items/graphql/award_emoji.fragment.graphql"
+
+query workItemAwardEmojis($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ workItems(iid: $iid) {
+ nodes {
+ id
+ iid
+ widgets {
+ ... on WorkItemWidgetAwardEmoji {
+ type
+ awardEmoji(first: $pageSize, after: $after) {
+ pageInfo {
+ ...PageInfo
+ }
+ nodes {
+ ...AwardEmojiFragment
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/cache_utils.js b/app/assets/javascripts/work_items/graphql/cache_utils.js
index 03b45a45c39..14eedf5cdd8 100644
--- a/app/assets/javascripts/work_items/graphql/cache_utils.js
+++ b/app/assets/javascripts/work_items/graphql/cache_utils.js
@@ -87,6 +87,46 @@ export const updateCacheAfterDeletingNote = (currentNotes, subscriptionData) =>
});
};
+function updateNoteAwardEmojiCache(currentNotes, note, callback) {
+ if (!note.awardEmoji) {
+ return currentNotes;
+ }
+ const { awardEmoji } = note;
+
+ return produce(currentNotes, (draftData) => {
+ const notesWidget = getNotesWidgetFromSourceData(draftData);
+
+ if (!notesWidget.discussions) {
+ return;
+ }
+
+ notesWidget.discussions.nodes.forEach((discussion) => {
+ discussion.notes.nodes.forEach((n) => {
+ if (n.id === note.id) {
+ callback(n, awardEmoji);
+ }
+ });
+ });
+
+ updateNotesWidgetDataInDraftData(draftData, notesWidget);
+ });
+}
+
+export const updateCacheAfterAddingAwardEmojiToNote = (currentNotes, note) => {
+ return updateNoteAwardEmojiCache(currentNotes, note, (n, awardEmoji) => {
+ n.awardEmoji.nodes.push(awardEmoji);
+ });
+};
+
+export const updateCacheAfterRemovingAwardEmojiFromNote = (currentNotes, note) => {
+ return updateNoteAwardEmojiCache(currentNotes, note, (n, awardEmoji) => {
+ // eslint-disable-next-line no-param-reassign
+ n.awardEmoji.nodes = n.awardEmoji.nodes.filter((emoji) => {
+ return emoji.name !== awardEmoji.name || emoji.user.id !== awardEmoji.user.id;
+ });
+ });
+};
+
export const addHierarchyChild = (cache, fullPath, iid, workItem) => {
const queryArgs = { query: workItemByIidQuery, variables: { fullPath, iid } };
const sourceData = cache.readQuery(queryArgs);
diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_todos.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_todos.mutation.graphql
new file mode 100644
index 00000000000..1eb08f8bf6f
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/create_work_item_todos.mutation.graphql
@@ -0,0 +1,9 @@
+mutation workItemTodoCreate($input: TodoCreateInput!) {
+ todoMutation: todoCreate(input: $input) {
+ todo {
+ id
+ state
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/mark_done_work_item_todos.mutation.graphql b/app/assets/javascripts/work_items/graphql/mark_done_work_item_todos.mutation.graphql
new file mode 100644
index 00000000000..2bfeaf93ae8
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/mark_done_work_item_todos.mutation.graphql
@@ -0,0 +1,9 @@
+mutation workItemTodoMarkDone($input: TodoMarkDoneInput!) {
+ todoMutation: todoMarkDone(input: $input) {
+ todo {
+ id
+ state
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql
index c8b7d379074..6543e1a52f9 100644
--- a/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql
@@ -1,4 +1,5 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/work_items/graphql/award_emoji.fragment.graphql"
fragment WorkItemNote on Note {
id
@@ -22,6 +23,11 @@ fragment WorkItemNote on Note {
author {
...User
}
+ awardEmoji {
+ nodes {
+ ...AwardEmojiFragment
+ }
+ }
userPermissions {
adminNote
awardEmoji
diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql
index dc51c53428b..bc228c0dd3d 100644
--- a/app/assets/javascripts/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql
@@ -1,17 +1,5 @@
-#import "~/graphql_shared/fragments/user.fragment.graphql"
-
mutation workItemNoteAddAwardEmoji($awardableId: AwardableID!, $name: String!) {
awardEmojiAdd(input: { awardableId: $awardableId, name: $name }) {
- awardEmoji {
- name
- description
- unicode
- emoji
- unicodeVersion
- user {
- ...User
- }
- }
errors
}
}
diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note_remove_award_emoji.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_remove_award_emoji.mutation.graphql
new file mode 100644
index 00000000000..22942fbb823
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_remove_award_emoji.mutation.graphql
@@ -0,0 +1,5 @@
+mutation workItemNoteRemoveAwardEmoji($awardableId: AwardableID!, $name: String!) {
+ awardEmojiRemove(input: { awardableId: $awardableId, name: $name }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_assignees.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_assignees.subscription.graphql
deleted file mode 100644
index d5b2de8c4c6..00000000000
--- a/app/assets/javascripts/work_items/graphql/work_item_assignees.subscription.graphql
+++ /dev/null
@@ -1,21 +0,0 @@
-#import "~/graphql_shared/fragments/user.fragment.graphql"
-
-subscription issuableAssignees($issuableId: IssuableID!) {
- issuableAssigneesUpdated(issuableId: $issuableId) {
- ... on WorkItem {
- id
- widgets {
- ... on WorkItemWidgetAssignees {
- type
- allowsMultipleAssignees
- canInviteMembers
- assignees {
- nodes {
- ...User
- }
- }
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_description.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_description.subscription.graphql
deleted file mode 100644
index 4eb3d8067d9..00000000000
--- a/app/assets/javascripts/work_items/graphql/work_item_description.subscription.graphql
+++ /dev/null
@@ -1,20 +0,0 @@
-subscription issuableDescription($issuableId: IssuableID!) {
- issuableDescriptionUpdated(issuableId: $issuableId) {
- ... on WorkItem {
- id
- widgets {
- ... on WorkItemWidgetDescription {
- type
- description
- descriptionHtml
- lastEditedAt
- lastEditedBy {
- id
- name
- webPath
- }
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_labels.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_labels.subscription.graphql
deleted file mode 100644
index 86d936bf4dd..00000000000
--- a/app/assets/javascripts/work_items/graphql/work_item_labels.subscription.graphql
+++ /dev/null
@@ -1,19 +0,0 @@
-#import "~/graphql_shared/fragments/label.fragment.graphql"
-
-subscription workItemLabels($issuableId: IssuableID!) {
- issuableLabelsUpdated(issuableId: $issuableId) {
- ... on WorkItem {
- id
- widgets {
- ... on WorkItemWidgetLabels {
- type
- labels {
- nodes {
- ...Label
- }
- }
- }
- }
- }
- }
-}
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 42c057fb8fe..f303a797e9c 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
@@ -1,7 +1,6 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/work_items/graphql/milestone.fragment.graphql"
-#import "~/work_items/graphql/award_emoji.fragment.graphql"
fragment WorkItemMetadataWidgets on WorkItemWidget {
... on WorkItemWidgetDescription {
@@ -45,20 +44,12 @@ fragment WorkItemMetadataWidgets on WorkItemWidget {
... on WorkItemWidgetCurrentUserTodos {
type
currentUserTodos(state: pending) {
- edges {
- node {
- id
- state
- }
+ nodes {
+ id
}
}
}
... on WorkItemWidgetAwardEmoji {
type
- awardEmoji {
- nodes {
- ...AwardEmojiFragment
- }
- }
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_milestone.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_milestone.subscription.graphql
deleted file mode 100644
index f5163003fe5..00000000000
--- a/app/assets/javascripts/work_items/graphql/work_item_milestone.subscription.graphql
+++ /dev/null
@@ -1,17 +0,0 @@
-#import "~/work_items/graphql/milestone.fragment.graphql"
-
-subscription issuableMilestone($issuableId: IssuableID!) {
- issuableMilestoneUpdated(issuableId: $issuableId) {
- ... on WorkItem {
- id
- widgets {
- ... on WorkItemWidgetMilestone {
- type
- milestone {
- ...MilestoneFragment
- }
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_title.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_title.subscription.graphql
deleted file mode 100644
index 2ac01b79d6f..00000000000
--- a/app/assets/javascripts/work_items/graphql/work_item_title.subscription.graphql
+++ /dev/null
@@ -1,8 +0,0 @@
-subscription issuableTitleUpdated($issuableId: IssuableID!) {
- issuableTitleUpdated(issuableId: $issuableId) {
- ... on WorkItem {
- id
- title
- }
- }
-}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_updated.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_updated.subscription.graphql
new file mode 100644
index 00000000000..8b63f46ab28
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/work_item_updated.subscription.graphql
@@ -0,0 +1,7 @@
+#import "./work_item.fragment.graphql"
+
+subscription workItemUpdated($id: WorkItemID!) {
+ workItemUpdated(workItemId: $id) {
+ ...WorkItem
+ }
+}
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 bf8dc9ce9b0..383d003e78c 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,6 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/work_items/graphql/milestone.fragment.graphql"
-#import "~/work_items/graphql/award_emoji.fragment.graphql"
#import "ee_else_ce/work_items/graphql/work_item_metadata_widgets.fragment.graphql"
fragment WorkItemWidgets on WorkItemWidget {
@@ -93,20 +92,12 @@ fragment WorkItemWidgets on WorkItemWidget {
... on WorkItemWidgetCurrentUserTodos {
type
currentUserTodos(state: pending) {
- edges {
- node {
- id
- state
- }
+ nodes {
+ id
}
}
}
... on WorkItemWidgetAwardEmoji {
type
- awardEmoji {
- nodes {
- ...AwardEmojiFragment
- }
- }
}
}
diff --git a/app/assets/javascripts/work_items/notes/award_utils.js b/app/assets/javascripts/work_items/notes/award_utils.js
new file mode 100644
index 00000000000..5351a22d593
--- /dev/null
+++ b/app/assets/javascripts/work_items/notes/award_utils.js
@@ -0,0 +1,67 @@
+import { __ } from '~/locale';
+import { TYPENAME_USER } from '~/graphql_shared/constants';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import {
+ updateCacheAfterAddingAwardEmojiToNote,
+ updateCacheAfterRemovingAwardEmojiFromNote,
+} from '~/work_items/graphql/cache_utils';
+import workItemNotesByIidQuery from '../graphql/notes/work_item_notes_by_iid.query.graphql';
+import addAwardEmojiMutation from '../graphql/notes/work_item_note_add_award_emoji.mutation.graphql';
+import removeAwardEmojiMutation from '../graphql/notes/work_item_note_remove_award_emoji.mutation.graphql';
+
+function awardedByCurrentUser(note) {
+ return (note.awardEmoji?.nodes ?? [])
+ .filter((award) => {
+ return getIdFromGraphQLId(award.user.id) === window.gon.current_user_id;
+ })
+ .map((award) => award.name);
+}
+
+export function getMutation({ note, name }) {
+ if (awardedByCurrentUser(note).includes(name)) {
+ return {
+ mutation: removeAwardEmojiMutation,
+ mutationName: 'awardEmojiRemove',
+ errorMessage: __('Failed to remove emoji. Please try again'),
+ };
+ }
+ return {
+ mutation: addAwardEmojiMutation,
+ mutationName: 'awardEmojiAdd',
+ errorMessage: __('Failed to add emoji. Please try again'),
+ };
+}
+
+export function optimisticAwardUpdate({ note, name, fullPath, workItemIid }) {
+ const { mutation } = getMutation({ note, name });
+
+ const currentUserId = window.gon.current_user_id;
+
+ return (store) => {
+ store.updateQuery(
+ {
+ query: workItemNotesByIidQuery,
+ variables: { fullPath, iid: workItemIid },
+ },
+ (sourceData) => {
+ const updatedNote = {
+ id: note.id,
+ awardEmoji: {
+ __typename: 'AwardEmoji',
+ name,
+ user: {
+ __typename: 'UserCore',
+ id: convertToGraphQLId(TYPENAME_USER, currentUserId),
+ name: null,
+ },
+ },
+ };
+
+ if (mutation === removeAwardEmojiMutation) {
+ return updateCacheAfterRemovingAwardEmojiFromNote(sourceData, updatedNote);
+ }
+ return updateCacheAfterAddingAwardEmojiToNote(sourceData, updatedNote);
+ },
+ );
+ };
+}
diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js
index 13fc521464f..81dbe56b2ea 100644
--- a/app/assets/javascripts/work_items/utils.js
+++ b/app/assets/javascripts/work_items/utils.js
@@ -1,20 +1,10 @@
-import { uniqueId } from 'lodash';
-import {
- WIDGET_TYPE_HIERARCHY,
- WIDGET_TYPE_CURRENT_USER_TODOS,
- CURRENT_USER_TODOS_TYPENAME,
- TODO_CONNECTION_TYPENAME,
- TODO_EDGE_TYPENAME,
- TODO_TYPENAME,
- WORK_ITEM_TYPENAME,
- WORK_ITEM_UPDATE_PAYLOAD_TYPENAME,
-} from '~/work_items/constants';
+import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants';
export const findHierarchyWidgets = (widgets) =>
widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY);
export const findHierarchyWidgetChildren = (workItem) =>
- findHierarchyWidgets(workItem?.widgets)?.children.nodes;
+ findHierarchyWidgets(workItem?.widgets)?.children?.nodes || [];
const autocompleteSourcesPath = (autocompleteType, fullPath, workItemIid) => {
return `${
@@ -32,38 +22,3 @@ export const markdownPreviewPath = (fullPath, iid) =>
`${
gon.relative_url_root || ''
}/${fullPath}/preview_markdown?target_type=WorkItem&target_id=${iid}`;
-
-export const getWorkItemTodoOptimisticResponse = ({ workItem, pendingTodo }) => {
- const todo = pendingTodo
- ? [
- {
- node: {
- id: -uniqueId(),
- state: 'pending',
- __typename: TODO_TYPENAME,
- },
- __typename: TODO_EDGE_TYPENAME,
- },
- ]
- : [];
- return {
- workItemUpdate: {
- errors: [],
- workItem: {
- ...workItem,
- widgets: [
- {
- type: WIDGET_TYPE_CURRENT_USER_TODOS,
- currentUserTodos: {
- edges: todo,
- __typename: TODO_CONNECTION_TYPENAME,
- },
- __typename: CURRENT_USER_TODOS_TYPENAME,
- },
- ],
- __typename: WORK_ITEM_TYPENAME,
- },
- __typename: WORK_ITEM_UPDATE_PAYLOAD_TYPENAME,
- },
- };
-};
diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss
index 6a6febbf7b4..23a7beb527b 100644
--- a/app/assets/stylesheets/components/avatar.scss
+++ b/app/assets/stylesheets/components/avatar.scss
@@ -189,3 +189,24 @@ $avatar-sizes: (
.avatar-counter {
@include avatar-counter();
}
+
+.user-popover {
+ // GlAvatarLabeled doesn't expose any prop to override internal classes
+
+ // Max width of popover container is set by gl-max-w-48
+ // so we need to ensure that name/username/status container doesn't overflow
+ .gl-avatar-labeled-labels {
+ max-width: px-to-rem(290px);
+ }
+
+ .gl-avatar-labeled-label,
+ .gl-avatar-labeled-sublabel {
+ @include gl-text-truncate;
+ }
+
+ &.user-popover-cannot-merge {
+ .popover-header {
+ @include gl-bg-orange-50;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss
index 2ed955a56b6..08a956bf90f 100644
--- a/app/assets/stylesheets/components/content_editor.scss
+++ b/app/assets/stylesheets/components/content_editor.scss
@@ -1,18 +1,38 @@
.ProseMirror {
+ width: calc(100% - 4px);
padding-top: $gl-spacing-scale-4;
+ padding-left: calc(#{$gl-spacing-scale-5} - 2px);
+ padding-right: $gl-spacing-scale-5;
+ margin: 2px;
min-height: 140px;
max-height: 55vh;
+ position: static;
overflow-y: auto;
+ transition: box-shadow ease-in-out 0.15s;
+
+ .gl-dark & {
+ width: calc(100% - 6px);
+ margin: 2px 3px;
+ padding-left: calc(#{$gl-spacing-scale-5} - 3px);
+ }
::selection {
background-color: transparent;
}
+ &:focus {
+ @include gl-focus;
+ }
+
&:not(.ProseMirror-hideselection) .content-editor-selection,
a.ProseMirror-selectednode,
span.ProseMirror-selectednode {
background-color: $blue-100;
- box-shadow: 0 2px 0 $blue-100, 0 -2px 0 $blue-100;
+ box-shadow: 0 2px 0 $blue-100, 0 -2px 0 $blue-100;
+ }
+
+ pre > code {
+ background-color: transparent;
}
td,
@@ -44,6 +64,48 @@
pointer-events: none;
}
+ .image-resize-container {
+ position: relative;
+ }
+
+ .image-resize {
+ display: inline-block;
+ position: absolute;
+ width: 8px;
+ height: 8px;
+ background: $blue-200;
+ outline: 1px solid $white;
+ }
+
+ .image-resize-nw {
+ top: -4px;
+ left: -4px;
+ cursor: nw-resize;
+ }
+
+ .image-resize-ne {
+ top: -4px;
+ right: -4px;
+ cursor: ne-resize;
+ }
+
+ .image-resize-sw {
+ bottom: 4px;
+ left: -4px;
+ cursor: sw-resize;
+ }
+
+ .image-resize-se {
+ bottom: 4px;
+ right: -4px;
+ cursor: se-resize;
+ }
+
+ img.ProseMirror-selectednode {
+ outline: 2px solid $blue-200;
+ outline-offset: -2px;
+ }
+
video {
max-width: 400px;
}
@@ -78,6 +140,81 @@
}
}
+ .suggestion-added,
+ .suggestion-deleted,
+ .suggestion-added-input {
+ white-space: pre-wrap;
+
+ > code {
+ white-space: pre-wrap;
+ padding: 0;
+ display: flex;
+ }
+ }
+
+ .suggestion-added-input {
+ > code {
+ display: block;
+ margin-left: 120px;
+ background-color: transparent !important;
+ }
+ }
+
+ .suggestion-added,
+ .suggestion-deleted {
+ background-color: $line-added;
+ width: 100%;
+
+ > code {
+ border-left: 100px solid $line-number-new;
+ padding-left: 20px;
+ border-radius: 0;
+ background-color: inherit !important;
+ }
+
+ > code::before {
+ content: attr(data-line-number);
+ position: absolute;
+ width: 100px;
+ margin-left: -120px;
+ text-align: right;
+ padding-right: 10px;
+ padding-left: 10px;
+
+ @include gl-text-secondary;
+ }
+
+ > code::after {
+ content: '+';
+ position: absolute;
+ margin-left: -20px;
+ width: 20px;
+ text-align: center;
+
+ @include gl-text-secondary;
+ }
+ }
+
+ .suggestion-added > code {
+ color: rgba($white, 0);
+ }
+
+ .suggestion-deleted {
+ background-color: $line-removed;
+ cursor: not-allowed;
+
+ > code {
+ border-color: $line-number-old;
+ }
+
+ > code::before {
+ padding-right: 60px;
+ }
+
+ > code::after {
+ content: '-';
+ }
+ }
.dl-content {
width: 100%;
@@ -135,6 +272,31 @@
}
}
+.gl-dark .ProseMirror {
+ .suggestion-added-input,
+ .suggestion-deleted {
+ > code {
+ color: $gray-50;
+ }
+ }
+
+ .suggestion-deleted,
+ .suggestion-added {
+ > code::before,
+ > code::after {
+ color: $gray-400;
+ }
+ }
+}
+
+// Fixes a problem with the layout shifting
+// when switching between Markdown and the
+// Richtext editor due to a loosly defined
+// style in typography.scss
+.md > .ProseMirror {
+ margin: 2px;
+}
+
.table-creator-grid-item {
box-shadow: inset 0 0 0 $gl-spacing-scale-2 $white,
inset $gl-spacing-scale-1 $gl-spacing-scale-1 0 #{$gl-spacing-scale-2 * 3 / 4} $gray-100,
@@ -179,6 +341,17 @@
min-width: auto;
}
+.content-editor-suggestions-dropdown {
+ .gl-new-dropdown-panel {
+ width: max-content;
+ }
+
+ li.focused div.gl-new-dropdown-item-content {
+ @include gl-focus($inset: true);
+ @include gl-bg-gray-50;
+ }
+}
+
.bubble-menu-form {
min-width: 320px;
}
diff --git a/app/assets/stylesheets/fonts.scss b/app/assets/stylesheets/fonts.scss
index bc49d17fcbb..0d87d49ac18 100644
--- a/app/assets/stylesheets/fonts.scss
+++ b/app/assets/stylesheets/fonts.scss
@@ -9,15 +9,26 @@ Usage:
font-weight: 100 900;
font-display: optional;
font-style: normal;
- font-named-instance: 'Regular'; /* stylelint-disable property-no-unknown */
+ /* stylelint-disable-next-line property-no-unknown */
+ font-named-instance: 'Regular';
src: font-url('gitlab-sans/GitLabSans.woff2') format('woff2');
}
+@font-face {
+ font-family: 'GitLab Sans';
+ font-weight: 100 900;
+ font-display: optional;
+ font-style: italic;
+ /* stylelint-disable-next-line property-no-unknown */
+ font-named-instance: 'Regular';
+ src: font-url('gitlab-sans/GitLabSans-Italic.woff2') format('woff2');
+}
+
/* -------------------------------------------------------
Monospaced font: GitLab Mono.
Usage:
- html { font-family: 'GitLab Mono', sans-serif; }
+ html { font-family: 'GitLab Mono', monospace; }
*/
@font-face {
font-family: 'GitLab Mono';
@@ -35,45 +46,6 @@ Usage:
src: font-url('gitlab-mono/GitLabMono-Italic.woff2') format('woff2');
}
-/* -------------------------------------------------------
-Monospaced font: JetBrains Mono.
-
-All of the definitions below can be removed once
-`GitLab Mono` is properly rolled out.
-
-Usage:
- html { font-family: 'JetBrains Mono', sans-serif; }
-*/
-@font-face {
- font-family: 'JetBrains Mono';
- font-display: optional;
- font-style: normal;
- 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');
-}
-
// This isn't the best solution, but we needed a quick fix
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/107592/
* {
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index e60353578b0..4d4144fe9dd 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -39,6 +39,7 @@
@import 'framework/contextual_sidebar_header';
@import 'framework/contextual_sidebar';
@import 'framework/super_sidebar';
+@import 'framework/brand_logo';
@import 'framework/tables';
@import 'framework/notes';
@import 'framework/tabs';
@@ -64,3 +65,4 @@
@import 'framework/card';
@import 'framework/source_editor';
@import 'framework/diffs';
+@import 'framework/new_card';
diff --git a/app/assets/stylesheets/framework/brand_logo.scss b/app/assets/stylesheets/framework/brand_logo.scss
new file mode 100644
index 00000000000..1bc1ef797a7
--- /dev/null
+++ b/app/assets/stylesheets/framework/brand_logo.scss
@@ -0,0 +1,29 @@
+$brand-logo-light-background: #e0dfe5;
+$brand-logo-dark-background: #53515b;
+
+.brand-logo {
+ display: inline-block;
+ @include gl-rounded-base;
+ @include gl-p-2;
+ @include gl-bg-transparent;
+ @include gl-border-none;
+
+ .tanuki-logo {
+ @include gl-vertical-align-middle;
+ }
+
+ &:focus,
+ &:active {
+ @include gl-focus;
+ }
+
+ &:hover,
+ &:focus,
+ &:active {
+ background-color: $brand-logo-light-background;
+
+ .gl-dark & {
+ background-color: $brand-logo-dark-background;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 2ec7c891197..7b8d9281148 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -34,11 +34,7 @@
--mr-review-bar-height: #{$mr-review-bar-height};
}
-@include media-breakpoint-up(md) {
- .page-with-contextual-sidebar {
- --application-bar-left: #{$contextual-sidebar-collapsed-width};
- }
-
+@include media-breakpoint-up(sm) {
.right-sidebar-collapsed {
--application-bar-right: #{$right-sidebar-collapsed-width};
@@ -52,6 +48,12 @@
}
}
+@include media-breakpoint-up(md) {
+ .page-with-contextual-sidebar {
+ --application-bar-left: #{$contextual-sidebar-collapsed-width};
+ }
+}
+
@include media-breakpoint-up(xl) {
.page-with-contextual-sidebar {
--application-bar-left: #{$contextual-sidebar-width};
@@ -330,14 +332,6 @@ li.note {
height: 220px;
}
-.footer-links {
- margin-bottom: 20px;
-
- a {
- margin-right: 15px;
- }
-}
-
.card.card-body {
margin-bottom: $gl-padding;
@@ -555,3 +549,16 @@ li.note {
See https://gitlab.com/gitlab-org/gitlab/issues/36857 for more details.
**/
.gl-line-height-14 { line-height: $gl-line-height-14; }
+
+// TODO: To be removed once `split` option for new dropdowns is implemented.
+// See issue at https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2263
+.gl-new-dropdown.split:nth-child(n+2) {
+ .gl-new-dropdown-toggle {
+ margin-left: 1px;
+
+ &.btn-tertiary,
+ &.disabled {
+ margin-left: -1px;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index 192cb82aaab..7b35659e90a 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -1,6 +1,6 @@
// Common
.diff-file {
- padding-bottom: $gl-padding;
+ margin-bottom: $gl-padding;
&.has-body {
.file-title {
@@ -864,19 +864,6 @@ table.code {
}
}
-// Remove border from collapsed replies widget only on diffs
-.diff-grid-comments {
- .replies-widget-collapsed {
- border-bottom: 0;
- }
- // Rounded border radius only on diff comments with no replies
- .discussion-collapsible {
- .discussion-reply-holder:first-of-type {
- border-radius: $gl-border-radius-base;
- }
- }
-}
-
.discussion-body .image .frame {
position: relative;
}
@@ -889,13 +876,6 @@ table.code {
}
}
-.parallel {
- .discussion-collapsible {
- margin: $gl-padding;
- margin-top: 0;
- }
-}
-
.image-diff-overlay,
.image-diff-overlay-add-comment {
top: 0;
diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss
index 16ad6f62c64..358f599e0e9 100644
--- a/app/assets/stylesheets/framework/emojis.scss
+++ b/app/assets/stylesheets/framework/emojis.scss
@@ -3,8 +3,11 @@ gl-emoji {
display: inline-flex;
vertical-align: baseline;
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
- font-size: 1.2em;
- line-height: 1;
+
+ img {
+ width: 1.2em;
+ height: 1.2em;
+ }
}
.user-status-emoji {
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index b2ba1d8830d..f5ed85e8845 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -8,7 +8,7 @@ $search-input-field-x-min-width: 200px;
min-height: $header-height;
border: 0;
position: fixed;
- top: $calc-application-bars-height;
+ top: $calc-system-headers-height;
left: 0;
right: 0;
border-radius: 0;
@@ -322,7 +322,7 @@ $search-input-field-x-min-width: 200px;
left: var(--application-bar-left);
position: fixed;
right: var(--application-bar-right);
- top: $calc-application-bars-height;
+ top: $calc-system-headers-height;
width: auto;
z-index: $top-bar-z-index;
diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss
index 7dfbd5485d8..086a16edda2 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -36,7 +36,7 @@ body {
}
.layout-page {
- padding-top: calc(#{$header-height} + #{$calc-application-bars-height});
+ padding-top: $calc-application-bars-height;
padding-bottom: $calc-application-footer-height;
}
@@ -62,11 +62,6 @@ body {
}
}
-.navless-container {
- margin-top: $header-height;
- padding-top: $gl-padding * 2;
-}
-
.container-limited {
max-width: $fixed-layout-width;
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 5fdab7891ec..f8f54567ef2 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -91,7 +91,7 @@
}
.md-preview-holder {
- min-height: 176px;
+ min-height: 173px;
padding: 10px 0;
overflow-x: auto;
}
@@ -106,6 +106,7 @@
box-shadow: none;
width: 100%;
resize: none !important;
+ transition: box-shadow $gl-transition-duration-medium ease;
}
.md-suggestion-diff {
diff --git a/app/assets/stylesheets/framework/new_card.scss b/app/assets/stylesheets/framework/new_card.scss
new file mode 100644
index 00000000000..ef8f5cc1d1b
--- /dev/null
+++ b/app/assets/stylesheets/framework/new_card.scss
@@ -0,0 +1,94 @@
+.gl-new-card {
+ @include gl-mt-5;
+ @include gl-bg-gray-10;
+ @include gl-border-1;
+ @include gl-border-solid;
+ @include gl-border-gray-100;
+ @include gl-rounded-base;
+
+ &-header {
+ @include gl-px-5;
+ @include gl-py-4;
+ @include gl-display-flex;
+ @include gl-justify-content-space-between;
+ @include gl-bg-white;
+ @include gl-border-b-1;
+ @include gl-border-b-solid;
+ @include gl-border-b-gray-100;
+ @include gl-rounded-top-base;
+ }
+
+ &[aria-expanded=false] &-header {
+ @include gl-border-bottom-0;
+ @include gl-rounded-base;
+ }
+
+ &-title-wrapper {
+ @include gl-display-flex;
+ @include gl-flex-grow-1;
+ }
+
+ &-title {
+ @include gl-display-flex;
+ @include gl-font-base;
+ @include gl-font-weight-bold;
+ @include gl-relative;
+ @include gl-m-0;
+ @include gl-line-height-24;
+ }
+
+ &-count {
+ @include gl-mx-3;
+ @include gl-font-base;
+ @include gl-font-weight-bold;
+ @include gl-text-gray-500;
+ @include gl-display-inline-flex;
+ @include gl-align-items-center;
+ }
+
+ &-description {
+ @include gl-font-sm;
+ @include gl-text-gray-500;
+ @include gl-m-0;
+ }
+
+ &-toggle {
+ @include gl-pl-3;
+ @include gl-ml-3;
+ @include gl-mr-n2;
+ @include gl-border-l-1;
+ @include gl-border-l-solid;
+ @include gl-border-l-gray-100;
+ }
+
+ &-body {
+ @include gl-rounded-bottom-base;
+ @include gl-px-3;
+ @include gl-py-0;
+ }
+
+ &-content {
+ @include gl-px-2;
+ @include gl-py-3;
+ }
+
+ &-empty {
+ @include gl-p-2;
+ @include gl-mb-0;
+ @include gl-text-gray-500;
+ }
+
+ &-footer {
+ @include gl-bg-white;
+ }
+
+ &-add-form {
+ @include gl-p-4;
+ @include gl-my-2;
+ @include gl-bg-white;
+ @include gl-border-1;
+ @include gl-border-solid;
+ @include gl-border-gray-100;
+ @include gl-rounded-base;
+ }
+}
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index 98083fbc72a..9bf6ed45483 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -39,6 +39,12 @@
}
.approvers-select {
+ width: calc(70% - #{$gl-spacing-scale-5});
+
+ .gl-new-dropdown-toggle {
+ @include gl-w-full;
+ }
+
.dropdown-menu {
@include gl-w-full;
@include gl-max-w-none;
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index b7a674a35e7..5f90dd62426 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -317,7 +317,7 @@
.right-sidebar {
&:not(.right-sidebar-merge-requests) {
@include right-sidebar;
- top: calc(#{$header-height} + #{$calc-application-bars-height});
+ top: $calc-application-bars-height;
@include media-breakpoint-down(md) {
z-index: 251;
@@ -327,7 +327,7 @@
&.right-sidebar-merge-requests {
@include media-breakpoint-down(md) {
@include right-sidebar;
- top: calc(#{$header-height} + #{$calc-application-bars-height});
+ top: $calc-application-bars-height;
z-index: 251;
}
diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss
index ca67b472322..12801b272e8 100644
--- a/app/assets/stylesheets/framework/super_sidebar.scss
+++ b/app/assets/stylesheets/framework/super_sidebar.scss
@@ -23,7 +23,7 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4;
}
.super-sidebar-skip-to {
- top: calc(#{$header-height} + #{$calc-application-bars-height});
+ top: $calc-application-bars-height;
width: calc(#{$super-sidebar-width} - #{$gl-spacing-scale-5});
z-index: $super-sidebar-skip-to-z-index;
}
@@ -32,7 +32,7 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4;
display: flex;
flex-direction: column;
position: fixed;
- top: calc(#{$header-height} + #{$calc-application-bars-height});
+ top: $calc-application-bars-height;
bottom: $calc-application-footer-height;
left: 0;
background-color: var(--gray-10, $gray-10);
@@ -57,12 +57,7 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4;
.user-bar {
background-color: $t-gray-a-04;
- .tanuki-logo {
- @include gl-vertical-align-middle;
- }
-
- .user-bar-item,
- .tanuki-logo-container {
+ .user-bar-item {
@include gl-rounded-base;
@include gl-p-2;
@include gl-bg-transparent;
@@ -81,21 +76,6 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4;
@include active-toggle;
}
}
-
- $light-mode-btn-bg: #e0dfe5;
- $dark-mode-btn-bg: #53515b;
-
- .tanuki-logo-container {
- &:hover,
- &:focus,
- &:active {
- background-color: $light-mode-btn-bg;
-
- .gl-dark & {
- background-color: $dark-mode-btn-bg;
- }
- }
- }
}
.counter .gl-icon,
@@ -313,12 +293,9 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4;
padding: 0.5rem !important;
}
- .is-searching {
- .in-search-scope-help {
- position: absolute;
- top: 0.625rem;
- right: 2.5rem;
- }
+ .search-scope-help {
+ top: 0.625rem;
+ right: 2.5rem;
}
.gl-search-box-by-type-input-borderless {
diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss
index 703c2ca0dad..5a8ef077c9b 100644
--- a/app/assets/stylesheets/framework/system_messages.scss
+++ b/app/assets/stylesheets/framework/system_messages.scss
@@ -36,16 +36,6 @@
}
}
-// System Footer
-.with-system-footer {
- // navless pages' footer eg: login page
- // navless pages' footer border eg: login page
- &.devise-layout-html body .footer-container,
- &.devise-layout-html body hr.footer-fixed {
- bottom: $system-footer-height;
- }
-}
-
.fullscreen-layout {
.header-message,
.footer-message {
diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss
index a3b238d657d..921e03f45f3 100644
--- a/app/assets/stylesheets/framework/timeline.scss
+++ b/app/assets/stylesheets/framework/timeline.scss
@@ -31,7 +31,6 @@
&:not(.note-form).internal-note .timeline-content,
&:not(.note-form).draft-note .timeline-content {
background-color: $orange-50 !important;
- border-radius: 3px;
}
.timeline-entry-inner {
@@ -40,9 +39,12 @@
&:target,
&.target {
- .timeline-content,
+ .timeline-content {
+ background-color: $line-target-blue;
+ }
+
+ .public-note.discussion-reply-holder {
- background-color: $line-target-blue !important;
+ padding-top: $gl-padding-12 !important;
}
&.system-note .note-body .note-text.system-note-commit-list::after {
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 88f990d2320..25542a86e8c 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -577,17 +577,6 @@
width: 1%;
}
- .metrics-embed {
- h3.popover-header {
- /** Override <h3> .popover-header
- * as embed metrics do not follow the same
- * style as default md <h3> (which are deeply nested)
- */
- margin: 0;
- font-size: $gl-font-size-small;
- }
- }
-
.gl-dropdown-item {
margin: 0;
padding: 0;
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index f77804fb7fc..ebaaece1281 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -10,7 +10,7 @@ $default-transition-duration: 0.15s;
$contextual-sidebar-width: 256px;
$contextual-sidebar-collapsed-width: 56px;
$toggle-sidebar-height: 48px;
-$super-sidebar-width: 256px;
+$super-sidebar-width: 16rem;
$super-sidebar-z-index: 600;
$super-sidebar-skip-to-z-index: 601;
$super-sidebar-overlay-z-index: 599;
@@ -467,7 +467,6 @@ $ide-statusbar-height: 25px;
$fixed-layout-width: 1280px;
$limited-layout-width: 990px;
$container-text-max-width: 540px;
-$gl-avatar-size: 40px;
$border-radius-default: 4px;
$border-radius-small: 2px;
$border-radius-large: 8px;
@@ -502,8 +501,9 @@ $pages-group-name-color: #4c4e54;
/*
* Calculated heights
*/
-$calc-application-bars-height: calc(var(--system-header-height) + var(--performance-bar-height));
-$calc-application-header-height: calc(#{$header-height} + #{$calc-application-bars-height} + var(--top-bar-height));
+$calc-system-headers-height: calc(var(--system-header-height) + var(--performance-bar-height));
+$calc-application-bars-height: calc(#{$header-height} + #{$calc-system-headers-height});
+$calc-application-header-height: calc(#{$calc-application-bars-height} + var(--top-bar-height));
$calc-application-footer-height: var(--system-footer-height);
$calc-application-viewport-height: calc(100vh - #{$calc-application-header-height} - #{$calc-application-footer-height});
@@ -568,10 +568,12 @@ $diff-jagged-border-gradient-color: darken($white-normal, 8%);
/*
* Fonts
+ * The --default-mono-font and --default-regular-font variables give users
+ * a way to override our font choices for them.
*/
-$monospace-font: 'GitLab Mono', 'JetBrains Mono', 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas',
+$monospace-font: var(--default-mono-font, 'GitLab Mono'), 'JetBrains Mono', 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas',
'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace;
-$regular-font: 'GitLab Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans',
+$regular-font: var(--default-regular-font, 'GitLab Sans'), -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans',
Ubuntu, Cantarell, 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
$gl-monospace-font: $monospace-font;
@@ -704,7 +706,6 @@ $environment-logs-difference-md-up: calc(#{$header-height} + #{$environment-logs
* Avatar
*/
$avatar-radius: 50%;
-$gl-avatar-size: 40px;
$gl-avatar-border-opacity: 0.1;
/*
diff --git a/app/assets/stylesheets/page_bundles/branches.scss b/app/assets/stylesheets/page_bundles/branches.scss
index a5b201c7dac..daf828fb559 100644
--- a/app/assets/stylesheets/page_bundles/branches.scss
+++ b/app/assets/stylesheets/page_bundles/branches.scss
@@ -43,3 +43,15 @@
.branches-list .branch-item:not(:last-of-type) {
border-bottom: 1px solid $border-color;
}
+
+.branch-item {
+ .issuable-reference {
+ max-width: 92px;
+ }
+
+ .right-block {
+ @media (min-width: map-get($grid-breakpoints, md)) {
+ min-width: 200px;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/design_management.scss b/app/assets/stylesheets/page_bundles/design_management.scss
index c19561a5e5e..e206b5e5b8b 100644
--- a/app/assets/stylesheets/page_bundles/design_management.scss
+++ b/app/assets/stylesheets/page_bundles/design_management.scss
@@ -115,11 +115,11 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
flex-basis: 28%;
.link-inherit-color {
+ &,
&:hover,
&:active,
&:focus {
color: inherit;
- text-decoration: none;
}
}
@@ -159,27 +159,14 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
transition: background $gl-transition-duration-medium $general-hover-transition-curve;
border-top-left-radius: $border-radius-default; // same border radius used by .bordered-box
border-top-right-radius: $border-radius-default;
-
- a {
- color: inherit;
- }
-
- .note-text a {
- color: var(--blue-600, $blue-600);
- }
}
.reply-wrapper {
- padding: $gl-padding-8 $gl-padding-8 $gl-padding-4;
- background: $gray-10;
+ padding: $gl-padding-8;
border-radius: 0 0 $border-radius-default $border-radius-default;
}
}
- .reply-wrapper {
- border-top: 1px solid var(--border-color, $border-color);
- }
-
.new-discussion-disclaimer {
line-height: 20px;
}
diff --git a/app/assets/stylesheets/page_bundles/issuable.scss b/app/assets/stylesheets/page_bundles/issuable.scss
index 1b98fd4df07..1b5da0368c6 100644
--- a/app/assets/stylesheets/page_bundles/issuable.scss
+++ b/app/assets/stylesheets/page_bundles/issuable.scss
@@ -149,6 +149,10 @@
.gl-search-box-by-type button.gl-clear-icon-button:hover {
@include gl-bg-transparent;
+
+ &:focus {
+ @include gl-focus($inset: true);
+ }
}
.issuable-move-button:not(.disabled):hover {
diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss
index 2c54c819543..6972e98b0bf 100644
--- a/app/assets/stylesheets/page_bundles/jira_connect.scss
+++ b/app/assets/stylesheets/page_bundles/jira_connect.scss
@@ -9,6 +9,8 @@
@import '@gitlab/ui/src/components/base/alert/alert';
@import '@gitlab/ui/src/components/base/avatar/avatar';
@import '@gitlab/ui/src/components/base/button/button';
+@import '@gitlab/ui/src/components/base/banner/banner';
+@import '@gitlab/ui/src/components/base/card/card';
@import '@gitlab/ui/src/components/base/icon/icon';
@import '@gitlab/ui/src/components/base/link/link';
@import '@gitlab/ui/src/components/base/loading_icon/loading_icon';
@@ -23,7 +25,7 @@
@import '@gitlab/ui/src/components/base/form/form_group/form_group';
@import '@gitlab/ui/src/components/base/search_box_by_type/search_box_by_type';
-$header-height: 40px;
+$header-height: $gl-spacing-scale-8;
.jira-connect-header {
min-height: $header-height;
@@ -35,6 +37,6 @@ $header-height: 40px;
.jira-connect-app {
margin-top: $header-height;
- height: calc(100% - #{$header-height});
- max-width: 1000px;
+ height: 100%;
+ max-height: calc(100% - #{$header-height + $gl-spacing-scale-7 * 2});
}
diff --git a/app/assets/stylesheets/page_bundles/login.scss b/app/assets/stylesheets/page_bundles/login.scss
index 355d2afc0ba..b63f199f7b9 100644
--- a/app/assets/stylesheets/page_bundles/login.scss
+++ b/app/assets/stylesheets/page_bundles/login.scss
@@ -196,10 +196,6 @@
}
}
- .submit-container {
- margin-top: 16px;
- }
-
input[type='submit'] {
margin-bottom: 0;
display: block;
@@ -228,65 +224,33 @@
}
}
-.devise-layout-html {
+.html-devise-layout {
margin: 0;
padding: 0;
height: 100%;
- &.with-system-header {
- .login-page-broadcast {
- margin-top: calc(#{$system-header-height} + #{$header-height});
- }
- }
-
- // Fixes footer container to bottom of viewport
body {
- // offset height of fixed header + 1 to avoid scroll
- height: calc(100% - 51px);
+ padding-top: 48px; // Remove this line when the restyle_login_page feature flag is deleted. Instead, add self-align `center` to container, and maybe a top margin.
- // offset without the header
- &.navless {
- height: calc(100% - 11px);
+ &.with-system-header {
+ padding-top: $system-header-height;
+ padding-top: calc(#{$system-header-height} + 48px); // Remove this line when the restyle_login_page feature flag is deleted
}
- margin: 0;
- padding: 0;
-
- .page-wrap {
- min-height: 100%;
- position: relative;
- }
-
- .footer-container,
- hr.footer-fixed {
- position: fixed;
- bottom: 0;
- left: 0;
- right: 0;
- height: 40px;
- background: var(--white, $white);
- }
-
- .login-page-broadcast {
- margin-top: 40px;
- }
-
- .navless-container {
- padding: 0 15px 65px; // height of footer + bottom padding of email confirmation link
- }
-
- .flash-container {
- padding-bottom: 65px;
-
- @include media-breakpoint-down(xs) {
- padding-bottom: 0;
+ &.with-system-footer {
+ .footer-container {
+ padding-bottom: $system-footer-height;
}
}
}
}
@include media-breakpoint-down(sm) {
- .sm-bg-gray-10 {
+ .sm-bg-gray {
@include gl-bg-gray-10;
+
+ .gl-dark & {
+ background-color: var(--gray-100);
+ }
}
}
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index fc4a9d3dff9..5e20588dd70 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -226,7 +226,7 @@ $tabs-holder-z-index: 250;
clear: left;
.note-body {
- padding: 0 0 $gl-padding-8;
+ padding: 0 $gl-padding-8 $gl-padding-8 $gl-padding-32;
}
}
@@ -234,14 +234,15 @@ $tabs-holder-z-index: 250;
margin-top: -2px;
margin-right: $gl-padding-8;
}
+}
- // tiny adjustment to vertical align with the note header text
- .discussion-collapsible {
- margin-left: 1rem;
+// tiny adjustment to vertical align with the note header text
+.discussion-collapsible {
+ border: 0 !important;
+ margin: 0;
- .timeline-icon {
- padding-top: 2px;
- }
+ .timeline-icon {
+ padding-top: 2px;
}
}
@@ -1275,20 +1276,12 @@ $tabs-holder-z-index: 250;
.diff-file-discussions-wrapper {
@include gl-w-full;
- max-width: 800px;
-
- .diff-discussions > .notes {
- @include gl-p-5;
- }
-
.diff-discussions:not(:first-child) >.notes {
@include gl-pt-0;
}
.note-discussion {
- @include gl-rounded-base;
-
- border: 1px solid var(--gray-100, $gray-100) !important;
+ border-bottom: 1px solid var(--gray-100, $gray-100) !important;
}
.discussion-collapsible {
diff --git a/app/assets/stylesheets/page_bundles/notifications.scss b/app/assets/stylesheets/page_bundles/notifications.scss
index 88437954f4c..a901235df50 100644
--- a/app/assets/stylesheets/page_bundles/notifications.scss
+++ b/app/assets/stylesheets/page_bundles/notifications.scss
@@ -1,31 +1,7 @@
@import 'mixins_and_variables_and_functions';
.notification-list-item {
- @include media-breakpoint-down(sm) {
- .notification-dropdown {
- width: 100%;
- }
-
- .btn-group {
- width: 100%;
- }
-
- .table-section {
- border-top: 0;
- min-height: unset;
-
- &:not(:first-child) {
- padding-top: 0;
- }
- }
-
- .update-notifications {
- width: 100%;
- }
+ &:not(:last-of-type) {
+ border-bottom: 1px solid $gray-100;
}
}
-
-.notification {
- position: relative;
- top: 1px;
-}
diff --git a/app/assets/stylesheets/page_bundles/profiles/preferences.scss b/app/assets/stylesheets/page_bundles/profiles/preferences.scss
index c9c78a70163..1a59f96c6ee 100644
--- a/app/assets/stylesheets/page_bundles/profiles/preferences.scss
+++ b/app/assets/stylesheets/page_bundles/profiles/preferences.scss
@@ -66,13 +66,8 @@
.syntax-theme {
label {
- margin-right: $gl-padding-32;
- margin-bottom: $gl-padding;
- text-align: center;
-
.preview {
- margin-bottom: 10px;
- width: 160px;
+ margin-bottom: 8px;
img {
border-radius: 4px;
diff --git a/app/assets/stylesheets/page_bundles/project.scss b/app/assets/stylesheets/page_bundles/project.scss
index 68bf2fa0f82..8d8da10268a 100644
--- a/app/assets/stylesheets/page_bundles/project.scss
+++ b/app/assets/stylesheets/page_bundles/project.scss
@@ -47,12 +47,6 @@
}
.project-repo-buttons {
- .btn {
- svg {
- fill: var(--gray-500, $gray-500);
- }
- }
-
.download-button {
@include media-breakpoint-down(md) {
margin-left: 0;
diff --git a/app/assets/stylesheets/page_bundles/prometheus.scss b/app/assets/stylesheets/page_bundles/prometheus.scss
deleted file mode 100644
index 702c0e4dd72..00000000000
--- a/app/assets/stylesheets/page_bundles/prometheus.scss
+++ /dev/null
@@ -1,113 +0,0 @@
-@import 'mixins_and_variables_and_functions';
-
-.date-time-picker {
- .date-time-picker-menu {
- width: 400px;
- }
-}
-
-.prometheus-graphs {
- .dropdown-buttons {
- > div {
- margin-left: auto;
- }
- }
-
- .col-form-label {
- line-height: 1;
- padding-top: 0;
- }
-
- .form-group {
- margin-bottom: map-get($spacing-scale, 3);
- }
-
- .variables-section {
- input {
- @include media-breakpoint-up(sm) {
- width: 160px;
- }
- }
- }
-
- .links-section {
- .gl-hover-text-blue-600-children:hover {
- * {
- @include gl-text-blue-600;
- }
- }
- }
-}
-
-.draggable {
- &.draggable-enabled {
- .draggable-panel {
- border: $gray-100 1px solid;
- border-radius: $border-radius-default;
- margin: -1px;
- cursor: grab;
- }
-
- .prometheus-graph {
- // Make dragging easier by disabling use of chart
- pointer-events: none;
- }
- }
-
- &.sortable-chosen .draggable-panel {
- background: $white;
- box-shadow: 0 0 4px $gray-300;
- }
-
- .draggable-remove {
- z-index: 1;
-
- .draggable-remove-link {
- cursor: pointer;
- color: $gray-400;
- background-color: $white;
- }
- }
-}
-
-.prometheus-graphs-header {
- .monitor-environment-dropdown-menu,
- .monitor-dashboard-dropdown-menu {
- &.show {
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
-
- .no-matches-message {
- padding: $gl-padding-8 $gl-padding-12;
- }
- }
-
- .show-last-dropdown {
- // same as in .dropdown-menu-toggle
- // see app/assets/stylesheets/framework/dropdowns.scss
- width: 160px;
- }
-}
-
-.prometheus-panel {
- margin-top: 20px;
-}
-
-.prometheus-graph-group {
- display: flex;
- flex-wrap: wrap;
-}
-
-.prometheus-graph {
- padding: $gl-padding-8;
-}
-
-.prometheus-panel-builder {
- .preview-date-time-picker {
- // same as in .dropdown-menu-toggle
- // see app/assets/stylesheets/framework/dropdowns.scss
- width: 160px;
- }
-}
diff --git a/app/assets/stylesheets/page_bundles/search.scss b/app/assets/stylesheets/page_bundles/search.scss
index d1d14cbcddd..a3a62b44e98 100644
--- a/app/assets/stylesheets/page_bundles/search.scss
+++ b/app/assets/stylesheets/page_bundles/search.scss
@@ -69,11 +69,9 @@ $language-filter-max-height: 20rem;
}
.label-with-color-checkbox {
- max-height: $gl-spacing-scale-5;
-
.custom-control-label {
+ display: flex;
margin-bottom: 0;
- max-height: $gl-spacing-scale-5;
.label-title {
margin-left: -$gl-spacing-scale-2;
diff --git a/app/assets/stylesheets/page_bundles/settings.scss b/app/assets/stylesheets/page_bundles/settings.scss
index 9a0d7880734..b906a932e70 100644
--- a/app/assets/stylesheets/page_bundles/settings.scss
+++ b/app/assets/stylesheets/page_bundles/settings.scss
@@ -65,6 +65,8 @@
}
.settings-content {
+ // #416312: Fix white space at bottom of page
+ position: relative;
max-height: 1px;
overflow-y: hidden;
padding-right: 110px;
diff --git a/app/assets/stylesheets/page_bundles/tree.scss b/app/assets/stylesheets/page_bundles/tree.scss
index a13b8704095..e0ee157187b 100644
--- a/app/assets/stylesheets/page_bundles/tree.scss
+++ b/app/assets/stylesheets/page_bundles/tree.scss
@@ -205,17 +205,6 @@
margin-top: $gl-padding;
}
-
-.web-ide-promo-popover {
- box-shadow: 0 0 18px -1.9px rgba(119, 89, 194, 0.16),
- 0 0 12.9px -1.7px rgba(119, 89, 194, 0.16), 0 0 9.2px -1.4px rgba(119, 89, 194, 0.16),
- 0 0 6.4px -1.1px rgba(119, 89, 194, 0.16), 0 0 4.5px -0.8px rgba(119, 89, 194, 0.16),
- 0 0 3px -0.6px rgba(119, 89, 194, 0.16), 0 0 1.8px -0.3px rgba(119, 89, 194, 0.16),
- 0 0 0.6px rgba(119, 89, 194, 0.16);
- z-index: 999;
-}
-
-.web-ide-promo-popover-illustration {
- width: calc(100% + 24px);
- margin: -28px -12px 0;
+.edit-dropdown-group-width {
+ width: 320px;
}
diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss
index ecbb872e1df..013aa064c4e 100644
--- a/app/assets/stylesheets/page_bundles/work_items.scss
+++ b/app/assets/stylesheets/page_bundles/work_items.scss
@@ -1,5 +1,8 @@
@import 'mixins_and_variables_and_functions';
+$work-item-overview-right-sidebar-width: 340px;
+$work-item-sticky-header-height: 52px;
+
.gl-token-selector-token-container {
display: flex;
align-items: center;
@@ -104,3 +107,54 @@
@include gl-font-weight-normal;
}
}
+
+.work-item-overview {
+ @include media-breakpoint-up(md) {
+ display: grid;
+ grid-template-columns: 1fr $work-item-overview-right-sidebar-width;
+ gap: 2rem;
+ }
+}
+
+.work-item-overview-right-sidebar {
+ @include media-breakpoint-up(md) {
+ &.is-modal {
+ .work-item-attributes-wrapper {
+ top: 0;
+ }
+ }
+ }
+}
+
+.work-item-attributes-wrapper {
+ .work-item-overview & {
+ @include media-breakpoint-up(md) {
+ top: calc(#{$calc-application-header-height} + #{$work-item-sticky-header-height});
+ height: calc(#{$calc-application-viewport-height} - #{$work-item-sticky-header-height});
+ margin-bottom: calc(#{$content-wrapper-padding} * -1);
+ position: sticky;
+ overflow-y: auto;
+ overflow-x: hidden;
+ }
+ }
+}
+
+.work-item-field-label {
+ .work-item-overview & {
+ max-width: 30%;
+ flex: none;
+ }
+}
+
+.work-item-field-value {
+ .work-item-overview & {
+ max-width: 65%;
+ }
+}
+
+.token-selector-menu-class {
+ .work-item-overview & {
+ width: 100%;
+ min-width: 100%;
+ }
+}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index b25a5b1c493..8b093e7bb7b 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -307,14 +307,6 @@
}
}
-.gpg-popover-user-link {
- display: flex;
- align-items: center;
- margin-bottom: $gl-padding / 2;
- text-decoration: none;
- color: $gl-text-color;
-}
-
.add-review-item {
.gl-tab-nav-item {
height: 100%;
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 322363d7f4b..0c9d151e3cd 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -48,7 +48,7 @@
.common-note-form {
.md-area {
- border: 1px solid $border-color;
+ border: 1px solid $gray-400;
border-radius: $border-radius-large;
transition: border-color ease-in-out 0.15s,
box-shadow ease-in-out 0.15s;
@@ -65,19 +65,41 @@
}
}
- // Disable inner focus
- textarea:focus {
- @include gl-shadow-none;
+ &:hover,
+ &:focus-within {
+ @include gl-shadow-md;
}
- }
- .comment-warning-wrapper:focus-within {
- @include gl-focus;
- }
-}
+ &:hover {
+ border: 1px solid $gray-500;
+ }
-.md-area:focus-within {
- @include gl-focus;
+ &:focus-within {
+ border: 1px solid $gray-900;
+ }
+
+ // Add focus
+ .zen-backdrop:not(.fullscreen) textarea {
+ width: calc(100% - 4px);
+ margin: 2px;
+ padding-left: calc(#{$gl-spacing-scale-5} - 2px);
+
+ .gl-dark & {
+ width: calc(100% - 6px);
+ margin: 2px 3px;
+ padding-left: calc(#{$gl-spacing-scale-5} - 3px);
+ }
+
+ &:focus {
+ @include gl-focus;
+ }
+ }
+
+ .note-textarea-rounded-bottom {
+ border-bottom-left-radius: calc(#{$border-radius-large} - 1px);
+ border-bottom-right-radius: calc(#{$border-radius-large} - 1px);
+ }
+ }
}
.md-header {
@@ -217,6 +239,7 @@ table {
.md-area {
background-color: $white;
+ @include gl-rounded-base;
}
}
@@ -245,24 +268,21 @@ table {
.diff-file,
.commit-diff {
.discussion-reply-holder {
- background-color: $gray-light;
border-radius: 0 0 $gl-border-radius-base $gl-border-radius-base;
- padding: $gl-padding;
+ padding: 0 $gl-padding $gl-padding-12 $gl-padding;
border-top: 1px solid $gray-50;
+ .new-note {
- background-color: $gray-light;
border-top: 1px solid $gray-50;
}
&.is-replying {
- padding-bottom: $gl-padding;
- background-color: $white;
+ padding-top: $gl-padding-12;
}
&.internal-note,
&.internal-note.is-replying {
- background-color: $orange-50;
+ padding-top: $gl-padding-12 !important;
}
.user-avatar-link {
@@ -273,6 +293,11 @@ table {
}
}
+.diff-td > .content > .discussion-reply-holder {
+ padding-top: $gl-padding-12;
+ @include gl-bg-gray-10;
+}
+
.discussion-with-resolve-btn {
@include media-breakpoint-up(sm) {
display: flex;
@@ -307,13 +332,19 @@ table {
resize: none;
padding: $gl-padding-8 $gl-padding-12;
line-height: 1;
- border: 1px solid $border-color;
+ border: 1px solid $gray-200;
background-color: $white;
overflow: hidden;
+ transition: border-color ease-in-out 0.15s,
+ box-shadow ease-in-out 0.15s;
@include media-breakpoint-down(xs) {
margin-bottom: $gl-padding-8;
}
+
+ &:hover {
+ border: 1px solid $gray-500;
+ }
}
}
@@ -348,10 +379,6 @@ table {
.toolbar-text {
font-size: 14px;
line-height: $gl-spacing-scale-7;
-
- @include media-breakpoint-up(md) {
- float: left;
- }
}
.note-form-actions {
@@ -438,9 +465,4 @@ table {
.comment-warning-wrapper {
transition: border-color ease-in-out 0.15s,
box-shadow ease-in-out 0.15s;
-
- .md-area {
- border: 0;
- box-shadow: none;
- }
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index c5b644bd72f..005fbc8b058 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -100,10 +100,13 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
margin-left: 2.5rem;
border: 1px solid $border-color;
border-radius: $gl-border-radius-base;
- background-color: $white;
padding: $gl-padding-4 $gl-padding-8;
}
+ &:not(.target) .timeline-content:not(.flash-container) {
+ background-color: $white;
+ }
+
&.draft-note .timeline-content:not(.flash-container) {
border: 0;
}
@@ -139,11 +142,14 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
border-top: 1px solid $border-color;
border-top-left-radius: $gl-border-radius-base;
border-top-right-radius: $gl-border-radius-base;
- background-color: $white;
padding: $gl-padding-4 $gl-padding-8;
}
}
+ &:not(.target) .timeline-content:not(.flash-container) {
+ background-color: $white;
+ }
+
&.draft-note .timeline-content:not(.flash-container) {
margin-left: 0;
border-top-left-radius: 0;
@@ -239,15 +245,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
}
}
- .discussion-toggle-replies {
- border-top: 0;
- border-radius: 4px 4px 0 0;
-
- &.collapsed {
- border-radius: 4px;
- }
- }
-
.note-created-ago,
.note-updated-at {
white-space: normal;
@@ -1090,6 +1087,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
margin-left: 0;
border-left: 0;
border-right: 0;
+ border-radius: 0 !important;
}
.discussion-reply-holder {
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index ff1987f35b3..8cf0bebfc4e 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -518,64 +518,24 @@
}
}
-.project-refs-form .dropdown-menu {
- width: 300px;
- @include media-breakpoint-up(sm) {
- width: 500px;
- }
-
- a {
- white-space: normal;
- }
-}
-
-.compare-form-group {
- .dropdown-menu,
- .inline-input-group {
- width: 100%;
-
- @include media-breakpoint-up(sm) {
- width: 300px;
+.compare-revision-cards {
+ @media (max-width: $breakpoint-lg) {
+ .swap-button {
+ display: none;
}
}
- + .compare-ellipsis {
- width: 100%;
- vertical-align: middle;
- text-align: center;
- margin-top: -20px;
-
- @include media-breakpoint-up(sm) {
- margin: 0 $gl-padding-8;
- width: auto;
+ @media (max-width: $breakpoint-lg) {
+ .swap-button-mobile {
+ display: flex;
}
}
- // Remove once gitlab/ui solution is implemented:
- // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1157
- // https://gitlab.com/gitlab-org/gitlab/-/issues/300405
- .gl-search-box-by-type-input {
- width: 100%;
- }
-
- // Remove once gitlab/ui solution is implemented
- // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1158
- // https://gitlab.com/gitlab-org/gitlab/-/issues/300405
- .gl-dropdown-button-text {
- @include str-truncated;
- }
-}
-
-.compare-revision-cards {
@media (min-width: $breakpoint-lg) {
.gl-card {
width: calc(50% - 15px);
}
-
- .compare-ellipsis {
- width: 30px;
- }
}
}
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 3b5e234c6b8..728eb1fe441 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -75,3 +75,70 @@
}
}
}
+
+.settings-section {
+ @include gl-pt-6;
+
+ &::after {
+ content: '';
+ display: block;
+ @include gl-pb-5;
+ }
+}
+
+.settings-section,
+.settings-section-no-bottom + .settings-section {
+ @include gl-pt-0;
+}
+
+.settings-section ~ .settings-section {
+ @include gl-pt-6;
+}
+
+.settings-section:not(.settings-section-no-bottom) + .settings-section {
+ @include gl-border-t;
+}
+
+.settings-section-no-bottom::after {
+ @include gl-pb-0;
+
+ @include media-breakpoint-up(sm) {
+ @include gl-pb-5;
+ }
+}
+
+$sticky-header-z-index: 98;
+
+.settings-sticky-header,
+.settings-sticky-footer {
+ position: sticky;
+ z-index: $sticky-header-z-index;
+ background: $body-bg;
+}
+
+.settings-sticky-header {
+ top: $calc-application-header-height;
+
+ &::before {
+ content: '';
+ display: block;
+ height: $gl-padding-8;
+ position: sticky;
+ top: calc(#{$calc-application-header-height} + 40px);
+ box-shadow: 0 1px 1px $gray-200;
+ }
+}
+
+.settings-sticky-header-inner {
+ position: sticky;
+ padding: $gl-padding $gl-padding $gl-padding-12;
+ margin: #{-$gl-padding} #{-$gl-padding} 0;
+ background: $body-bg;
+}
+
+.settings-sticky-footer {
+ bottom: 0;
+ padding-top: $gl-padding-8;
+ padding-bottom: $gl-padding-8;
+ box-shadow: 0 #{-$gl-padding-4} $gl-padding-12 $gl-padding-4 $body-bg;
+}
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index 7be15c2d8f9..60cbcffd506 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -20,9 +20,10 @@ header {
}
body {
margin: 0;
- font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
- Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif,
- "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ font-family: var(--default-regular-font, "GitLab Sans"), -apple-system,
+ BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell,
+ "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
+ "Segoe UI Symbol", "Noto Color Emoji";
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
@@ -50,9 +51,9 @@ a:not([href]):not([class]) {
text-decoration: none;
}
kbd {
- font-family: "GitLab Mono", "JetBrains Mono", "Menlo", "DejaVu Sans Mono",
- "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono",
- "lucida console", monospace;
+ font-family: var(--default-mono-font, "GitLab Mono"), "JetBrains Mono",
+ "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono",
+ "Courier New", "andale mono", "lucida console", monospace;
font-size: 1em;
}
img {
@@ -415,9 +416,10 @@ a.gl-badge.badge-warning:active {
.gl-form-input,
.gl-form-input.form-control {
background-color: #333238;
- font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
- Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif,
- "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ font-family: var(--default-regular-font, "GitLab Sans"), -apple-system,
+ BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell,
+ "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
+ "Segoe UI Symbol", "Noto Color Emoji";
font-size: 0.875rem;
line-height: 1rem;
padding-top: 0.5rem;
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index 65500800ce3..04c44dd9603 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -20,9 +20,10 @@ header {
}
body {
margin: 0;
- font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
- Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif,
- "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ font-family: var(--default-regular-font, "GitLab Sans"), -apple-system,
+ BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell,
+ "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
+ "Segoe UI Symbol", "Noto Color Emoji";
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
@@ -50,9 +51,9 @@ a:not([href]):not([class]) {
text-decoration: none;
}
kbd {
- font-family: "GitLab Mono", "JetBrains Mono", "Menlo", "DejaVu Sans Mono",
- "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono",
- "lucida console", monospace;
+ font-family: var(--default-mono-font, "GitLab Mono"), "JetBrains Mono",
+ "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono",
+ "Courier New", "andale mono", "lucida console", monospace;
font-size: 1em;
}
img {
@@ -415,9 +416,10 @@ a.gl-badge.badge-warning:active {
.gl-form-input,
.gl-form-input.form-control {
background-color: #fff;
- font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
- Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif,
- "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ font-family: var(--default-regular-font, "GitLab Sans"), -apple-system,
+ BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell,
+ "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
+ "Segoe UI Symbol", "Noto Color Emoji";
font-size: 0.875rem;
line-height: 1rem;
padding-top: 0.5rem;
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index 40e1e4b1996..32da8e1bb6b 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -19,9 +19,10 @@ header {
}
body {
margin: 0;
- font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
- Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif,
- "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ font-family: var(--default-regular-font, "GitLab Sans"), -apple-system,
+ BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell,
+ "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
+ "Segoe UI Symbol", "Noto Color Emoji";
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
@@ -382,9 +383,10 @@ input.btn-block[type="submit"] {
.gl-form-input,
.gl-form-input.form-control {
background-color: #fff;
- font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
- Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif,
- "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ font-family: var(--default-regular-font, "GitLab Sans"), -apple-system,
+ BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell,
+ "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
+ "Segoe UI Symbol", "Noto Color Emoji";
font-size: 0.875rem;
line-height: 1rem;
padding-top: 0.5rem;
@@ -622,10 +624,6 @@ body.navless {
margin-top: 20px;
}
}
-.navless-container {
- margin-top: var(--header-height, 48px);
- padding-top: 32px;
-}
.btn {
border-radius: 4px;
font-size: 0.875rem;
@@ -685,12 +683,6 @@ hr {
margin: 1.5rem 0;
border-top: 1px solid #ececef;
}
-.footer-links {
- margin-bottom: 20px;
-}
-.footer-links a {
- margin-right: 15px;
-}
.flash-container {
margin: 0;
margin-bottom: 16px;
@@ -777,9 +769,15 @@ svg {
.gl-align-items-center {
align-items: center;
}
+.gl-flex-wrap {
+ flex-wrap: wrap;
+}
.gl-justify-content-space-between {
justify-content: space-between;
}
+.gl-align-self-end {
+ align-self: flex-end;
+}
.gl-w-10 {
width: 3.5rem;
}
@@ -794,6 +792,9 @@ svg {
width: 100%;
}
}
+.gl-h-full {
+ height: 100%;
+}
.gl-p-5 {
padding: 1rem;
}
@@ -805,6 +806,9 @@ svg {
padding-top: 1rem;
padding-bottom: 1rem;
}
+.gl-m-0 {
+ margin: 0;
+}
.gl-mt-3 {
margin-top: 0.5rem;
}
@@ -823,6 +827,9 @@ svg {
.gl-ml-auto {
margin-left: auto;
}
+.gl-gap-5 {
+ gap: 1rem;
+}
@media (min-width: 576px) {
.gl-sm-mt-0 {
margin-top: 0;
diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss
index e004ca4bb4a..030e41046d3 100644
--- a/app/assets/stylesheets/themes/dark_mode_overrides.scss
+++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss
@@ -296,8 +296,7 @@ body.gl-dark {
}
.timeline-entry.internal-note:not(.note-form) .timeline-content,
-.timeline-entry.draft-note:not(.note-form) .timeline-content,
-.discussion-reply-holder.internal-note {
+.timeline-entry.draft-note:not(.note-form) .timeline-content {
// soften on darkmode
background-color: mix($gray-50, $orange-50, 75%) !important;
}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 08c4efce542..db9802eeefa 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -42,7 +42,7 @@
// Override Bootstrap class with offset for system-header and
// performance bar when present
.fixed-top {
- top: $calc-application-bars-height;
+ top: $calc-system-headers-height;
}
.gl-children-ml-sm-3 > * {
@@ -128,24 +128,6 @@
}
}
-.gl-md-w-15 {
- @include gl-media-breakpoint-up(md) {
- width: $gl-spacing-scale-15;
- }
-}
-
-.gl-md-w-20 {
- @include gl-media-breakpoint-up(md) {
- width: $gl-spacing-scale-20;
- }
-}
-
-.gl-md-w-30 {
- @include gl-media-breakpoint-up(md) {
- width: $gl-spacing-scale-30;
- }
-}
-
.gl-fill-orange-500 {
fill: $orange-500;
}
diff --git a/app/assets/stylesheets/vendors/atwho.scss b/app/assets/stylesheets/vendors/atwho.scss
index b92331facee..cf7dc79c5f5 100644
--- a/app/assets/stylesheets/vendors/atwho.scss
+++ b/app/assets/stylesheets/vendors/atwho.scss
@@ -2,6 +2,11 @@
overflow-y: auto;
overflow-x: hidden;
max-width: calc(100% - 6px);
+ @include gl-border-b-1;
+ @include gl-border-b-solid;
+ @include gl-border-b-gray-100;
+ @include gl-rounded-lg;
+ @include gl-shadow-md;
.name,
small.aliases,
@@ -44,11 +49,15 @@
// TODO: fallback to global style
.atwho-view-ul {
- padding: 8px 1px;
+ @include gl-p-2;
+ max-height: $gl-max-dropdown-max-height;
li {
- padding: 8px 16px;
+ @include gl-px-3;
+ padding-top: $gl-padding-6;
+ padding-bottom: $gl-padding-6;
border: 0;
+ @include gl-rounded-base;
&.cur {
background-color: $gray-darker;
@@ -67,15 +76,25 @@
display: inline-flex;
justify-content: center;
align-items: center;
+ }
- .center {
- line-height: 14px;
- }
+ .center {
+ line-height: 14px;
}
strong {
color: $gl-text-color;
}
+
+ gl-emoji {
+ @include gl-mr-2;
+ }
+
+ .dropdown-label-box {
+ position: relative;
+ top: -1px;
+ @include gl-mr-2;
+ }
}
}
}
diff --git a/app/components/pajamas/banner_component.html.haml b/app/components/pajamas/banner_component.html.haml
index 4fa2ed09cd3..c2eeae2d8c9 100644
--- a/app/components/pajamas/banner_component.html.haml
+++ b/app/components/pajamas/banner_component.html.haml
@@ -14,7 +14,7 @@
- if primary_action?
= primary_action
- else
- = link_to @button_text, @button_link, { **@button_options, class: 'btn btn-md btn-confirm gl-button js-close-callout' }
+ = link_button_to @button_text, @button_link, **@button_options, class: 'js-close-callout', variant: :confirm
- actions.each do |action|
= action
diff --git a/app/components/pajamas/banner_component.rb b/app/components/pajamas/banner_component.rb
index 6082762f22c..1a03f3fdd58 100644
--- a/app/components/pajamas/banner_component.rb
+++ b/app/components/pajamas/banner_component.rb
@@ -49,7 +49,7 @@ module Pajamas
end
end
- delegate :sprite_icon, to: :helpers
+ delegate :sprite_icon, :link_button_to, to: :helpers
renders_one :title
renders_one :illustration
diff --git a/app/components/pajamas/empty_state_component.html.haml b/app/components/pajamas/empty_state_component.html.haml
new file mode 100644
index 00000000000..ecd3498c5cd
--- /dev/null
+++ b/app/components/pajamas/empty_state_component.html.haml
@@ -0,0 +1,29 @@
+- empty_state_class = @compact ? 'gl-flex-direction-row gl-align-items-center' : 'gl-text-center gl-flex-direction-column'
+
+%section.gl-display-flex.empty-state{ **@empty_state_options, class: empty_state_class }
+ - if @svg_path.present?
+ - image_class = @compact ? 'gl-display-none gl-sm-display-block gl-px-4' : 'gl-max-w-full'
+ %div{ class: image_class }
+ = image_tag @svg_path, alt: "", class: 'gl-dark-invert-keep-hue'
+
+ - content_wrapper_class = @compact ? 'gl-flex-grow-1 gl-flex-basis-0 gl-px-4' : 'gl-max-w-full gl-m-auto pl-p-5'
+ %div{ class: content_wrapper_class }
+ - title_class = @compact ? 'gl-mt-0' : 'gl-my-3'
+ %h1.gl-font-size-h-display.gl-line-height-36{ class: title_class }
+ = @title
+
+ - if description?
+ %p.gl-mt-3{ 'data-testid': 'empty-state-description' }
+ = description
+
+ - if @primary_button_text.present? || @secondary_button_text.present?
+ - button_wrapper_class = @compact.present? ? '' : 'gl-justify-content-center'
+ .gl-display-flex.gl-flex-wrap{ class: button_wrapper_class }
+
+ - if @primary_button_text.present?
+ = render Pajamas::ButtonComponent.new(variant: :confirm, href: @primary_button_link, button_options: { class: 'gl-ml-0!' }) do
+ = @primary_button_text
+
+ - if @secondary_button_text.present?
+ = render Pajamas::ButtonComponent.new(variant: :default, href: @secondary_button_link, button_options: { class: ('gl-ml-0!' unless @primary_button_text.present?) }) do
+ = @secondary_button_text
diff --git a/app/components/pajamas/empty_state_component.rb b/app/components/pajamas/empty_state_component.rb
new file mode 100644
index 00000000000..d0c0da12d3b
--- /dev/null
+++ b/app/components/pajamas/empty_state_component.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Pajamas
+ class EmptyStateComponent < Pajamas::Component
+ # @param [Boolean] compact
+ # @param [String] title
+ # @param [String] svg_path
+ # @param [String] primary_button_text
+ # @param [String] primary_button_link
+ # @param [String] secondary_button_text
+ # @param [String] secondary_button_link
+ # @param [Hash] empty_state_options
+ def initialize(
+ compact: false,
+ title: nil,
+ svg_path: nil,
+ primary_button_text: nil,
+ primary_button_link: nil,
+ secondary_button_text: nil,
+ secondary_button_link: nil,
+ empty_state_options: {}
+ )
+ @compact = compact
+ @title = title
+ @svg_path = svg_path.to_s
+ @primary_button_text = primary_button_text
+ @primary_button_link = primary_button_link
+ @secondary_button_text = secondary_button_text
+ @secondary_button_link = secondary_button_link
+ @empty_state_options = empty_state_options
+ end
+
+ renders_one :description
+ end
+end
diff --git a/app/controllers/admin/application_settings/appearances_controller.rb b/app/controllers/admin/application_settings/appearances_controller.rb
index 719e8e4a913..1a1e85d48da 100644
--- a/app/controllers/admin/application_settings/appearances_controller.rb
+++ b/app/controllers/admin/application_settings/appearances_controller.rb
@@ -69,7 +69,7 @@ class Admin::ApplicationSettings::AppearancesController < Admin::ApplicationCont
@appearance = Appearance.current || Appearance.new
end
- # Only allow a trusted parameter "white list" through.
+ # Only allow a trusted parameter "allow list" through.
def appearance_params
params.require(:appearance).permit(allowed_appearance_params)
end
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index dff1c04311d..f0b6d86d48d 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -30,7 +30,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
feature_category :continuous_integration, [:ci_cd, :reset_registration_token]
urgency :low, [:ci_cd, :reset_registration_token]
feature_category :service_ping, [:usage_data, :service_usage_data]
- feature_category :integrations, [:integrations]
+ feature_category :integrations, [:integrations, :slack_app_manifest_share, :slack_app_manifest_download]
feature_category :pages, [:lets_encrypt_terms_of_service]
feature_category :error_tracking, [:reset_error_tracking_access_token]
@@ -114,6 +114,14 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
redirect_to ::Gitlab::LetsEncrypt.terms_of_service_url
end
+ def slack_app_manifest_share
+ redirect_to Slack::Manifest.share_url
+ end
+
+ def slack_app_manifest_download
+ send_data Slack::Manifest.to_json, type: :json, disposition: 'attachment', filename: 'slack_manifest.json'
+ end
+
private
def set_application_setting
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index f63616a2bea..b368ba6e495 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -3,32 +3,23 @@
class Admin::RunnersController < Admin::ApplicationController
include RunnerSetupScripts
- before_action :runner, except: [:index, :new, :tag_list, :runner_setup_scripts]
-
- before_action only: [:index] do
- push_frontend_feature_flag(:create_runner_workflow_for_admin, current_user)
- end
+ before_action :runner, only: [:show, :edit, :register, :update]
feature_category :runner
urgency :low
- def index
- end
+ def index; end
- def show
- end
+ def show; end
def edit
assign_projects
end
- def new
- render_404 unless Feature.enabled?(:create_runner_workflow_for_admin, current_user)
- end
+ def new; end
def register
- render_404 unless Feature.enabled?(:create_runner_workflow_for_admin, current_user) &&
- runner.registration_available?
+ render_404 unless runner.registration_available?
end
def update
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 3c96e49499f..b75ca2649c3 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -159,7 +159,7 @@ class Admin::UsersController < Admin::ApplicationController
end
def unlock
- if update_user(&:unlock_access!)
+ if unlock_user
redirect_back_or_admin_user(notice: _("Successfully unlocked"))
else
redirect_back_or_admin_user(alert: _("Error occurred. User was not unlocked"))
@@ -401,6 +401,11 @@ class Admin::UsersController < Admin::ApplicationController
_("You cannot impersonate a user who cannot log in")
end
end
+
+ # method overriden in EE
+ def unlock_user
+ update_user(&:unlock_access!)
+ end
end
Admin::UsersController.prepend_mod_with('Admin::UsersController')
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 08e4f4956df..8588273a41f 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -181,7 +181,7 @@ class ApplicationController < ActionController::Base
payload[:queue_duration_s] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY]
- payload[:response_bytes] = response.body_parts.sum(&:bytesize) if Feature.enabled?(:log_response_length)
+ payload[:response_bytes] = response.body_parts.sum(&:bytesize)
store_cloudflare_headers!(payload, request)
end
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index 2f6331a6822..b012a4e003e 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -2,7 +2,6 @@
class Clusters::ClustersController < Clusters::BaseController
include RoutableActions
- include MetricsDashboard
before_action :cluster, only: [:cluster_status, :show, :update, :destroy, :clear_cache]
before_action :user_cluster, only: [:connect]
diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb
index 19e458307a1..53dd06ce638 100644
--- a/app/controllers/concerns/integrations/params.rb
+++ b/app/controllers/concerns/integrations/params.rb
@@ -43,6 +43,8 @@ module Integrations
:external_wiki_url,
:google_iap_service_account_json,
:google_iap_audience_client_id,
+ :group_confidential_mention_events,
+ :group_mention_events,
:incident_events,
:inherit_from_id,
# We're using `issues_events` and `merge_requests_events`
diff --git a/app/controllers/concerns/internal_redirect.rb b/app/controllers/concerns/internal_redirect.rb
index b803be67d2e..c3aa487c805 100644
--- a/app/controllers/concerns/internal_redirect.rb
+++ b/app/controllers/concerns/internal_redirect.rb
@@ -6,7 +6,7 @@ module InternalRedirect
def safe_redirect_path(path)
return unless path
# Verify that the string starts with a `/` and a known route character.
- return unless path =~ %r{\A/[-\w].*\z}
+ return unless %r{\A/[-\w].*\z}.match?(path)
uri = URI(path)
# Ignore anything path of the redirect except for the path, querystring and,
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 0ad8a08960a..a326fa308ad 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -12,6 +12,7 @@ module IssuableActions
before_action :authorize_destroy_issuable!, only: :destroy
before_action :check_destroy_confirmation!, only: :destroy
before_action :authorize_admin_issuable!, only: :bulk_update
+ before_action :set_application_context!, only: :show
end
def show
@@ -226,6 +227,10 @@ module IssuableActions
render_404 unless can?(current_user, :"update_#{resource_name}", issuable)
end
+ def set_application_context!
+ # no-op. The logic is defined in EE module.
+ end
+
def bulk_update_params
clean_bulk_update_params(
params.require(:update).permit(bulk_update_permitted_keys)
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index 31675a58163..0c15c4d0d3f 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -16,7 +16,7 @@ module MembershipActions
member_data = if member.expires?
{
expires_soon: member.expires_soon?,
- expires_at_formatted: member.expires_at.to_time.in_time_zone.to_s(:medium)
+ expires_at_formatted: member.expires_at.to_time.in_time_zone.to_fs(:medium)
}
else
{}
diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb
deleted file mode 100644
index 7a84c597424..00000000000
--- a/app/controllers/concerns/metrics_dashboard.rb
+++ /dev/null
@@ -1,126 +0,0 @@
-# frozen_string_literal: true
-
-# Provides an action which fetches a metrics dashboard according
-# to the parameters specified by the controller.
-module MetricsDashboard
- include RenderServiceResults
- include ChecksCollaboration
- include EnvironmentsHelper
-
- extend ActiveSupport::Concern
-
- def metrics_dashboard
- return not_found if Feature.enabled?(:remove_monitor_metrics)
-
- result = dashboard_finder.find(
- project_for_dashboard,
- current_user,
- decoded_params
- )
-
- if result
- result[:all_dashboards] = all_dashboards if include_all_dashboards?
- result[:metrics_data] = metrics_data(project_for_dashboard, environment_for_dashboard)
- end
-
- respond_to do |format|
- if result.nil?
- format.json { continue_polling_response }
- elsif result[:status] == :success
- format.json { render dashboard_success_response(result) }
- else
- format.json { render dashboard_error_response(result) }
- end
- end
- end
-
- private
-
- def all_dashboards
- dashboard_finder
- .find_all_paths(project_for_dashboard)
- .map { |dashboard| amend_dashboard(dashboard) }
- end
-
- def amend_dashboard(dashboard)
- project_dashboard = project_for_dashboard && !dashboard[:out_of_the_box_dashboard]
-
- dashboard[:can_edit] = project_dashboard ? can_edit?(dashboard) : false
- dashboard[:project_blob_path] = project_dashboard ? dashboard_project_blob_path(dashboard) : nil
- dashboard[:starred] = starred_dashboards.include?(dashboard[:path])
- dashboard[:user_starred_path] = project_for_dashboard ? user_starred_path(project_for_dashboard, dashboard[:path]) : nil
-
- dashboard
- end
-
- def user_starred_path(project, path)
- expose_path(api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: path }))
- end
-
- def dashboard_project_blob_path(dashboard)
- project_blob_path(project_for_dashboard, File.join(project_for_dashboard.default_branch, dashboard.fetch(:path, "")))
- end
-
- def can_edit?(dashboard)
- can_collaborate_with_project?(project_for_dashboard, ref: project_for_dashboard.default_branch)
- end
-
- # Override in class to provide arguments to the finder.
- def metrics_dashboard_params
- {}
- end
-
- # Override in class if response requires complete list of
- # dashboards in addition to requested dashboard body.
- def include_all_dashboards?
- false
- end
-
- def dashboard_finder
- ::Gitlab::Metrics::Dashboard::Finder
- end
-
- def starred_dashboards
- @starred_dashboards ||=
- if project_for_dashboard.present?
- ::Metrics::UsersStarredDashboardsFinder
- .new(user: current_user, project: project_for_dashboard)
- .execute
- .map(&:dashboard_path)
- .to_set
- else
- Set.new
- end
- end
-
- # Project is not defined for group and admin level clusters.
- def project_for_dashboard
- defined?(project) ? project : nil
- end
-
- def environment_for_dashboard
- defined?(environment) ? environment : nil
- end
-
- def dashboard_success_response(result)
- {
- status: :ok,
- json: result.slice(:all_dashboards, :dashboard, :status, :metrics_data)
- }
- end
-
- def dashboard_error_response(result)
- {
- status: result[:http_status] || :bad_request,
- json: result.slice(:all_dashboards, :message, :status)
- }
- end
-
- def decoded_params
- params = metrics_dashboard_params
-
- params[:dashboard_path] = CGI.unescape(params[:dashboard_path]) if params[:dashboard_path]
-
- params
- end
-end
diff --git a/app/controllers/concerns/observability/content_security_policy.rb b/app/controllers/concerns/observability/content_security_policy.rb
index 1e25dc492a0..e51d986d36c 100644
--- a/app/controllers/concerns/observability/content_security_policy.rb
+++ b/app/controllers/concerns/observability/content_security_policy.rb
@@ -5,26 +5,23 @@ module Observability
extend ActiveSupport::Concern
included do
- 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? || !Feature.enabled?(:observability_group_tab, current_group)
+ content_security_policy do |p|
+ next if p.directives.blank?
default_frame_src = p.directives['frame-src'] || p.directives['default-src']
-
- # When ObservabilityUI is not authenticated, it needs to be able
- # to redirect to the GL sign-in page, hence '/users/sign_in' and '/oauth/authorize'
+ # When Gitlab Observability Backend is not authenticated, it needs to be able
+ # to redirect to the GitLab sign-in page, hence '/users/sign_in' and '/oauth/authorize'
frame_src_values = Array.wrap(default_frame_src) | [
Gitlab::Observability.observability_url,
Gitlab::Utils.append_path(Gitlab.config.gitlab.url, '/users/sign_in'),
Gitlab::Utils.append_path(Gitlab.config.gitlab.url, '/oauth/authorize')
]
-
p.frame_src(*frame_src_values)
+
+ default_connect_src = p.directives['connect-src'] || p.directives['default-src']
+ connect_src_values =
+ Array.wrap(default_connect_src) | [Gitlab::Observability.observability_url]
+ p.connect_src(*connect_src_values)
end
end
end
diff --git a/app/controllers/concerns/onboarding/status.rb b/app/controllers/concerns/onboarding/status.rb
new file mode 100644
index 00000000000..986f3f17847
--- /dev/null
+++ b/app/controllers/concerns/onboarding/status.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Onboarding
+ class Status
+ def initialize(user)
+ @user = user
+ end
+
+ def continue_full_onboarding?
+ false
+ end
+
+ def single_invite?
+ # If there are more than one member it will mean we have been invited to multiple projects/groups and
+ # are not able to distinguish which one we should putting the user in after registration
+ members.count == 1
+ end
+
+ def last_invited_member
+ members.last
+ end
+
+ def last_invited_member_source
+ last_invited_member&.source
+ end
+
+ def invite_with_tasks_to_be_done?
+ return false if members.empty?
+
+ MemberTask.for_members(members).exists?
+ end
+
+ private
+
+ attr_reader :user
+
+ def members
+ @members ||= user.members
+ end
+ end
+end
diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb
index a7655efe7a9..7f1b961e92a 100644
--- a/app/controllers/concerns/preview_markdown.rb
+++ b/app/controllers/concerns/preview_markdown.rb
@@ -48,9 +48,7 @@ module PreviewMarkdown
end.merge(
requested_path: params[:path],
ref: params[:ref],
- # Disable comments in markdown for IE browsers because comments in IE
- # could allow script execution.
- allow_comments: !browser.ie?
+ allow_comments: false
)
end
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 92574dfade9..97c23a2cf3c 100644
--- a/app/controllers/concerns/redirects_for_missing_path_on_tree.rb
+++ b/app/controllers/concerns/redirects_for_missing_path_on_tree.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
module RedirectsForMissingPathOnTree
- def redirect_to_tree_root_for_missing_path(project, ref, path)
- redirect_to project_tree_path(project, ref), notice: missing_path_on_ref(path, ref)
+ def redirect_to_tree_root_for_missing_path(project, ref, path, ref_type: nil)
+ redirect_to project_tree_path(project, ref, ref_type: ref_type), notice: missing_path_on_ref(path, ref)
end
private
diff --git a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb b/app/controllers/concerns/requires_allowlisted_monitoring_client.rb
index ef3d281589a..ad6d4dc548c 100644
--- a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
+++ b/app/controllers/concerns/requires_allowlisted_monitoring_client.rb
@@ -1,28 +1,28 @@
# frozen_string_literal: true
-module RequiresWhitelistedMonitoringClient
+module RequiresAllowlistedMonitoringClient
extend ActiveSupport::Concern
included do
- before_action :validate_ip_whitelisted_or_valid_token!
+ before_action :validate_ip_allowlisted_or_valid_token!
end
private
- def validate_ip_whitelisted_or_valid_token!
- render_404 unless client_ip_whitelisted? || valid_token?
+ def validate_ip_allowlisted_or_valid_token!
+ render_404 unless client_ip_allowlisted? || valid_token?
end
- def client_ip_whitelisted?
+ def client_ip_allowlisted?
# Always allow developers to access http://localhost:3000/-/metrics for
# debugging purposes
return true if Rails.env.development? && request.local?
- ip_whitelist.any? { |e| e.include?(Gitlab::RequestContext.instance.client_ip) }
+ ip_allowlist.any? { |e| e.include?(Gitlab::RequestContext.instance.client_ip) }
end
- def ip_whitelist
- @ip_whitelist ||= Settings.monitoring.ip_whitelist.map { |ip| IPAddr.new(ip) }
+ def ip_allowlist
+ @ip_allowlist ||= Settings.monitoring.ip_whitelist.map { |ip| IPAddr.new(ip) }
end
def valid_token?
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
index 222fcc17222..29b61264322 100644
--- a/app/controllers/concerns/uploads_actions.rb
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -110,7 +110,7 @@ module UploadsActions
if uploader_mounted?
model.public_send(upload_mount) # rubocop:disable GitlabSecurity/PublicSend
else
- build_uploader_from_upload || build_uploader_from_params
+ build_uploader_from_upload
end
end
strong_memoize_attr :uploader
@@ -125,21 +125,6 @@ module UploadsActions
end
# rubocop: enable CodeReuse/ActiveRecord
- def build_uploader_from_params
- return unless uploader = build_uploader
-
- uploader.retrieve_from_store!(params[:filename])
-
- Gitlab::AppJsonLogger.info(
- message: 'Deprecated usage of build_uploader_from_params',
- uploader_class: uploader.class.name,
- path: params[:filename],
- exists: uploader.exists?
- )
-
- uploader
- end
-
def build_uploader
return unless params[:secret] && params[:filename]
diff --git a/app/controllers/concerns/verifies_with_email.rb b/app/controllers/concerns/verifies_with_email.rb
index 45869c05f41..13378800ea9 100644
--- a/app/controllers/concerns/verifies_with_email.rb
+++ b/app/controllers/concerns/verifies_with_email.rb
@@ -25,6 +25,7 @@ module VerifiesWithEmail
if user.valid_password?(user_params[:password])
# The user has logged in successfully.
+
if user.unlock_token
# Prompt for the token if it already has been set
prompt_for_email_verification(user)
@@ -32,7 +33,8 @@ module VerifiesWithEmail
# require email verification if:
# - their account has been locked because of too many failed login attempts, or
# - they have logged in before, but never from the current ip address
- send_verification_instructions(user)
+ reason = 'sign in from untrusted IP address' unless user.access_locked?
+ send_verification_instructions(user, reason: reason)
prompt_for_email_verification(user)
end
end
@@ -75,13 +77,13 @@ module VerifiesWithEmail
super
end
- def send_verification_instructions(user)
+ def send_verification_instructions(user, reason: nil)
return if send_rate_limited?(user)
service = Users::EmailVerification::GenerateTokenService.new(attr: :unlock_token, user: user)
raw_token, encrypted_token = service.execute
user.unlock_token = encrypted_token
- user.lock_access!({ send_instructions: false })
+ user.lock_access!({ send_instructions: false, reason: reason })
send_verification_instructions_email(user, raw_token)
end
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index 577bd04d656..b3a1b510db9 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -10,6 +10,7 @@ class Explore::ProjectsController < Explore::ApplicationController
MIN_SEARCH_LENGTH = 3
PAGE_LIMIT = 50
+ RSS_ENTRIES_LIMIT = 20
before_action :set_non_archived_param
before_action :set_sorting
@@ -83,6 +84,14 @@ class Explore::ProjectsController < Explore::ApplicationController
params[:topic] = @topic.name
@projects = load_projects
+
+ respond_to do |format|
+ format.html
+ format.atom do
+ @projects = @projects.projects_order_id_desc.limit(RSS_ENTRIES_LIMIT)
+ render layout: 'xml'
+ end
+ end
end
private
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index 3d3b7f31dfd..5c0c2b4adf2 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -14,6 +14,7 @@ class GraphqlController < ApplicationController
# The query string of a standard IntrospectionQuery, used to compare incoming requests for caching
CACHED_INTROSPECTION_QUERY_STRING = CachedIntrospectionQuery.query_string
+ INTROSPECTION_QUERY_OPERATION_NAME = 'IntrospectionQuery'
# If a user is using their session to access GraphQL, we need to have session
# storage, since the admin-mode check is session wide.
@@ -58,7 +59,7 @@ class GraphqlController < ApplicationController
urgency :low, [:execute]
def execute
- result = if Feature.enabled?(:cache_introspection_query) && params[:operationName] == 'IntrospectionQuery'
+ result = if Feature.enabled?(:cache_introspection_query) && introspection_query?
execute_introspection_query
else
multiplex? ? execute_multiplex : execute_query
@@ -276,9 +277,6 @@ class GraphqlController < ApplicationController
def execute_introspection_query
if introspection_query_can_use_cache?
- Gitlab::AppLogger.info(message: "IntrospectionQueryCache hit")
- log_introspection_query_cache_details(true)
-
# Context for caching: https://gitlab.com/gitlab-org/gitlab/-/issues/409448
Rails.cache.fetch(
introspection_query_cache_key,
@@ -286,17 +284,12 @@ class GraphqlController < ApplicationController
execute_query.to_json
end
else
- Gitlab::AppLogger.info(message: "IntrospectionQueryCache miss")
- log_introspection_query_cache_details(false)
-
execute_query
end
end
def introspection_query_can_use_cache?
- graphql_query = GraphQL::Query.new(GitlabSchema, query: query, variables: build_variables(params[:variables]))
-
- CACHED_INTROSPECTION_QUERY_STRING == graphql_query.query_string.squish
+ CACHED_INTROSPECTION_QUERY_STRING == graphql_query_object.query_string.squish
end
def introspection_query_cache_key
@@ -306,13 +299,17 @@ class GraphqlController < ApplicationController
['introspection-query-cache', Gitlab.revision, context[:remove_deprecated]]
end
- def log_introspection_query_cache_details(can_use_introspection_query_cache)
- Gitlab::AppLogger.info(
- message: "IntrospectionQueryCache",
- can_use_introspection_query_cache: can_use_introspection_query_cache.to_s,
- query: query,
- variables: build_variables(params[:variables]).to_s,
- introspection_query_cache_key: introspection_query_cache_key.to_s
- )
+ def introspection_query?
+ if params.key?(:operationName)
+ params[:operationName] == INTROSPECTION_QUERY_OPERATION_NAME
+ else
+ # If we don't provide operationName param, we infer it from the query
+ graphql_query_object.selected_operation_name == INTROSPECTION_QUERY_OPERATION_NAME
+ end
+ end
+
+ def graphql_query_object
+ @graphql_query_object ||= GraphQL::Query.new(GitlabSchema, query: query,
+ variables: build_variables(params[:variables]))
end
end
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index 5f6b55ea928..cbed75019f2 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -9,6 +9,10 @@ class Groups::MilestonesController < Groups::ApplicationController
feature_category :team_planning
urgency :low
+ before_action do
+ push_frontend_feature_flag(:content_editor_on_issues, group)
+ end
+
def index
respond_to do |format|
format.html do
diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb
index 4b52617d287..2dd0e36b65f 100644
--- a/app/controllers/groups/runners_controller.rb
+++ b/app/controllers/groups/runners_controller.rb
@@ -6,10 +6,6 @@ class Groups::RunnersController < Groups::ApplicationController
before_action :authorize_update_runner!, only: [:edit, :update, :destroy, :pause, :resume]
before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show, :register]
- before_action only: [:index] do
- push_frontend_feature_flag(:create_runner_workflow_for_namespace, group)
- end
-
feature_category :runner
urgency :low
@@ -20,11 +16,9 @@ class Groups::RunnersController < Groups::ApplicationController
Gitlab::Tracking.event(self.class.name, 'index', user: current_user, namespace: @group)
end
- def show
- end
+ def show; end
- def edit
- end
+ def edit; end
def update
if Ci::Runners::UpdateRunnerService.new(@runner).execute(runner_params).success?
@@ -34,12 +28,10 @@ class Groups::RunnersController < Groups::ApplicationController
end
end
- def new
- render_404 unless create_runner_workflow_for_namespace_enabled?
- end
+ def new; end
def register
- render_404 unless create_runner_workflow_for_namespace_enabled? && runner.registration_available?
+ render_404 unless runner.registration_available?
end
private
@@ -67,10 +59,6 @@ class Groups::RunnersController < Groups::ApplicationController
render_404
end
-
- def create_runner_workflow_for_namespace_enabled?
- Feature.enabled?(:create_runner_workflow_for_namespace, group)
- end
end
Groups::RunnersController.prepend_mod
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index 4bbaf92b126..169caabf9d8 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -14,6 +14,7 @@ module Groups
feature_category :continuous_integration
before_action do
+ push_frontend_feature_flag(:ci_group_env_scope_graphql, group)
push_frontend_feature_flag(:ci_variables_pages, current_user)
end
diff --git a/app/controllers/groups/uploads_controller.rb b/app/controllers/groups/uploads_controller.rb
index cd1ebc39411..d29532f9d6f 100644
--- a/app/controllers/groups/uploads_controller.rb
+++ b/app/controllers/groups/uploads_controller.rb
@@ -9,7 +9,7 @@ class Groups::UploadsController < Groups::ApplicationController
before_action :authorize_upload_file!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]
- feature_category :groups_and_projects
+ feature_category :portfolio_management
urgency :low, [:show]
private
diff --git a/app/controllers/health_check_controller.rb b/app/controllers/health_check_controller.rb
index a2abed7ba4e..a85629985ba 100644
--- a/app/controllers/health_check_controller.rb
+++ b/app/controllers/health_check_controller.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
class HealthCheckController < HealthCheck::HealthCheckController
- include RequiresWhitelistedMonitoringClient
+ include RequiresAllowlistedMonitoringClient
end
diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb
index 5fac7c0d663..1381999ab4c 100644
--- a/app/controllers/health_controller.rb
+++ b/app/controllers/health_controller.rb
@@ -3,7 +3,7 @@
# rubocop:disable Rails/ApplicationController
class HealthController < ActionController::Base
protect_from_forgery with: :exception, prepend: true
- include RequiresWhitelistedMonitoringClient
+ include RequiresAllowlistedMonitoringClient
CHECKS = [
Gitlab::HealthChecks::MasterCheck
diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb
index bcb6aed9e38..f3a0ce64839 100644
--- a/app/controllers/import/base_controller.rb
+++ b/app/controllers/import/base_controller.rb
@@ -82,7 +82,7 @@ class Import::BaseController < ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def find_already_added_projects(import_type)
- current_user.created_projects.where(import_type: import_type).with_import_state
+ current_user.created_projects.inc_routes.where(import_type: import_type).with_import_state
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb
index 40664922d3d..e17cd00d053 100644
--- a/app/controllers/import/bitbucket_server_controller.rb
+++ b/app/controllers/import/bitbucket_server_controller.rb
@@ -103,8 +103,8 @@ class Import::BitbucketServerController < Import::BaseController
return render_validation_error('Missing project key') unless @project_key.present? && @repo_slug.present?
return render_validation_error('Missing repository slug') unless @repo_slug.present?
- return render_validation_error('Invalid project key') unless @project_key =~ VALID_BITBUCKET_PROJECT_CHARS
- return render_validation_error('Invalid repository slug') unless @repo_slug =~ VALID_BITBUCKET_CHARS
+ return render_validation_error('Invalid project key') unless VALID_BITBUCKET_PROJECT_CHARS.match?(@project_key)
+ return render_validation_error('Invalid repository slug') unless VALID_BITBUCKET_CHARS.match?(@repo_slug)
end
def render_validation_error(message)
diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb
index 3dfa8d7b11e..9f41c092fa0 100644
--- a/app/controllers/metrics_controller.rb
+++ b/app/controllers/metrics_controller.rb
@@ -2,7 +2,7 @@
# rubocop:disable Rails/ApplicationController
class MetricsController < ActionController::Base
- include RequiresWhitelistedMonitoringClient
+ include RequiresAllowlistedMonitoringClient
protect_from_forgery with: :exception, prepend: true
diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb
index 96a3fab7e1a..a1d4df6ff48 100644
--- a/app/controllers/oauth/authorizations_controller.rb
+++ b/app/controllers/oauth/authorizations_controller.rb
@@ -1,9 +1,11 @@
# frozen_string_literal: true
class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
+ include Gitlab::GonHelper
include InitializesCurrentUserMode
include Gitlab::Utils::StrongMemoize
+ before_action :add_gon_variables
before_action :verify_confirmed_email!, :verify_admin_allowed!
layout 'profile'
diff --git a/app/controllers/organizations/application_controller.rb b/app/controllers/organizations/application_controller.rb
index 5f5a57d176b..43cc7014f62 100644
--- a/app/controllers/organizations/application_controller.rb
+++ b/app/controllers/organizations/application_controller.rb
@@ -4,6 +4,8 @@ module Organizations
class ApplicationController < ::ApplicationController
before_action :organization
+ layout 'organization'
+
private
def organization
diff --git a/app/controllers/organizations/organizations_controller.rb b/app/controllers/organizations/organizations_controller.rb
index 0eb5c3aa6fd..4781ef995b7 100644
--- a/app/controllers/organizations/organizations_controller.rb
+++ b/app/controllers/organizations/organizations_controller.rb
@@ -6,6 +6,8 @@ module Organizations
before_action { authorize_action!(:admin_organization) }
- def directory; end
+ def show; end
+
+ def groups_and_projects; end
end
end
diff --git a/app/controllers/profiles/accounts_controller.rb b/app/controllers/profiles/accounts_controller.rb
index eb64016379d..f618eafea38 100644
--- a/app/controllers/profiles/accounts_controller.rb
+++ b/app/controllers/profiles/accounts_controller.rb
@@ -7,6 +7,7 @@ class Profiles::AccountsController < Profiles::ApplicationController
urgency :low, [:show]
def show
+ push_frontend_feature_flag(:delay_delete_own_user)
render(locals: show_view_variables)
end
diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb
index b663a75f04a..1477f8e0aac 100644
--- a/app/controllers/profiles/notifications_controller.rb
+++ b/app/controllers/profiles/notifications_controller.rb
@@ -45,7 +45,7 @@ class Profiles::NotificationsController < Profiles::ApplicationController
projects = project_notifications.map(&:source)
ActiveRecord::Associations::Preloader.new(
records: projects,
- associations: { namespace: [:route, :owner], group: [] }
+ associations: { namespace: [:route, :owner], group: [], creator: [] }
).call
Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute
diff --git a/app/controllers/projects/alerting/notifications_controller.rb b/app/controllers/projects/alerting/notifications_controller.rb
index 89e8a261288..281ac14d3ce 100644
--- a/app/controllers/projects/alerting/notifications_controller.rb
+++ b/app/controllers/projects/alerting/notifications_controller.rb
@@ -72,7 +72,7 @@ module Projects
end
def endpoint_identifier
- params[:endpoint_identifier] || AlertManagement::HttpIntegration::LEGACY_IDENTIFIER
+ params[:endpoint_identifier] || AlertManagement::HttpIntegration::LEGACY_IDENTIFIERS
end
def notification_payload
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 28393e1f365..b41e4d11d24 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -49,6 +49,7 @@ class Projects::BlobController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:highlight_js, @project)
+ push_frontend_feature_flag(:highlight_js_worker, @project)
push_frontend_feature_flag(:explain_code_chat, current_user)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end
@@ -160,6 +161,8 @@ class Projects::BlobController < Projects::ApplicationController
end
def check_for_ambiguous_ref
+ return if Feature.enabled?(:redirect_with_ref_type, @project)
+
@ref_type = ref_type
if @ref_type == ExtractsRef::BRANCH_REF_TYPE && ambiguous_ref?(@project, @ref)
@@ -169,7 +172,17 @@ class Projects::BlobController < Projects::ApplicationController
end
def commit
- @commit ||= @repository.commit(@ref)
+ if Feature.enabled?(:redirect_with_ref_type, @project)
+ response = ::ExtractsRef::RequestedRef.new(@repository, ref_type: ref_type, ref: @ref).find
+ @commit = response[:commit]
+ @ref_type = response[:ref_type]
+
+ if response[:ambiguous]
+ return redirect_to(project_blob_path(@project, File.join(@ref_type ? @ref : @commit.id, @path), ref_type: @ref_type))
+ end
+ else
+ @commit ||= @repository.commit(@ref)
+ end
return render_404 unless @commit
end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 10d0d03e56d..4cc1ed092d2 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -12,12 +12,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController
push_frontend_feature_flag(:environment_details_vue, @project)
end
- before_action only: [:index] do
- push_frontend_feature_flag(:kas_user_access_project, @project)
- end
-
- before_action only: [:edit, :new] do
- push_frontend_feature_flag(:environment_settings_to_graphql, @project)
+ before_action only: [:index, :edit, :new] do
+ push_frontend_feature_flag(:kubernetes_namespace_for_environment)
end
before_action :authorize_read_environment!
@@ -28,7 +24,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :cancel_auto_stop]
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? }
- before_action :set_kas_cookie, only: [:index], if: -> { current_user && request.format.html? }
+ before_action :set_kas_cookie, only: [:index, :edit, :new], if: -> { current_user && request.format.html? }
after_action :expire_etag_cache, only: [:cancel_auto_stop]
track_event :index, :folder, :show, :new, :edit, :create, :update, :stop, :cancel_auto_stop, :terminal,
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index ff3dc71b6cc..de2040afff3 100644
--- a/app/controllers/projects/forks_controller.rb
+++ b/app/controllers/projects/forks_controller.rb
@@ -65,9 +65,8 @@ class Projects::ForksController < Projects::ApplicationController
end
end
- # rubocop: disable CodeReuse/ActiveRecord
def create
- @forked_project = fork_namespace.projects.find_by(path: project.path)
+ @forked_project = fork_namespace.projects.find_by(path: project.path) # rubocop: disable CodeReuse/ActiveRecord
@forked_project = nil unless @forked_project && @forked_project.forked_from_project == project
@forked_project ||= fork_service.execute
@@ -96,7 +95,9 @@ class Projects::ForksController < Projects::ApplicationController
current_user: current_user
).execute
+ # rubocop: disable CodeReuse/ActiveRecord
forks.includes(:route, :creator, :group, :topics, namespace: [:route, :owner])
+ # rubocop: enable CodeReuse/ActiveRecord
end
def fork_service
@@ -130,15 +131,21 @@ class Projects::ForksController < Projects::ApplicationController
end
def load_namespaces_with_associations
+ # rubocop: disable CodeReuse/ActiveRecord
@load_namespaces_with_associations ||= fork_service.valid_fork_targets(only_groups: true).preload(:route)
+ # rubocop: enable CodeReuse/ActiveRecord
end
def memberships_hash
+ # rubocop: disable CodeReuse/ActiveRecord
current_user.members.where(source: load_namespaces_with_associations).index_by(&:source_id)
+ # rubocop: enable CodeReuse/ActiveRecord
end
def forked_projects_by_namespace(namespaces)
+ # rubocop: disable CodeReuse/ActiveRecord
project.forks.where(namespace: namespaces).includes(:namespace).index_by(&:namespace_id)
+ # rubocop: enable CodeReuse/ActiveRecord
end
end
diff --git a/app/controllers/projects/grafana_api_controller.rb b/app/controllers/projects/grafana_api_controller.rb
deleted file mode 100644
index 2cc6c6c35ba..00000000000
--- a/app/controllers/projects/grafana_api_controller.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-class Projects::GrafanaApiController < Projects::ApplicationController
- include RenderServiceResults
- include MetricsDashboard
-
- before_action :authorize_read_grafana!, only: :proxy
-
- feature_category :metrics
- urgency :low
-
- def proxy
- return not_found if Feature.enabled?(:remove_monitor_metrics)
-
- result = ::Grafana::ProxyService.new(
- project,
- params[:datasource_id],
- params[:proxy_path],
- prometheus_params
- ).execute
-
- return continue_polling_response if result.nil?
- return error_response(result) if result[:status] == :error
-
- success_response(result)
- end
-
- private
-
- def metrics_dashboard_params
- params.permit(:embedded, :grafana_url)
- end
-
- def query_params
- params.permit(:query, :start_time, :end_time, :step)
- end
-
- def prometheus_params
- query_params.to_h
- .except(:start_time, :end_time)
- .merge(
- start: query_params[:start_time],
- end: query_params[:end_time]
- )
- end
-end
diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb
index 7121096bd77..6109e29b169 100644
--- a/app/controllers/projects/incidents_controller.rb
+++ b/app/controllers/projects/incidents_controller.rb
@@ -11,6 +11,7 @@ class Projects::IncidentsController < 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(:moved_mr_sidebar, project)
+ push_frontend_feature_flag(:move_close_into_dropdown, project)
end
feature_category :incident_management
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 6311907a859..6a45595580f 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -51,13 +51,14 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:service_desk_new_note_email_native_attachments, project)
push_frontend_feature_flag(:saved_replies, current_user)
push_frontend_feature_flag(:issues_grid_view)
+ push_frontend_feature_flag(:service_desk_ticket)
end
before_action only: [:index, :show] do
push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?)
end
- before_action only: :index do
+ before_action only: [:index, :service_desk] do
push_frontend_feature_flag(:or_issuable_queries, project)
push_frontend_feature_flag(:frontend_caching, project&.group)
end
@@ -69,6 +70,7 @@ class Projects::IssuesController < Projects::ApplicationController
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(:moved_mr_sidebar, project)
+ push_frontend_feature_flag(:move_close_into_dropdown, project)
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 79ddcbf732d..4e0b304a2ee 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -21,7 +21,7 @@ class Projects::JobsController < Projects::ApplicationController
before_action :verify_proxy_request!, only: :proxy_websocket_authorize
before_action :push_job_log_jump_to_failures, only: [:show]
before_action :reject_if_build_artifacts_size_refreshing!, only: [:erase]
-
+ before_action :push_ai_build_failure_cause, only: [:show]
layout 'project'
feature_category :continuous_integration
@@ -258,4 +258,8 @@ class Projects::JobsController < Projects::ApplicationController
def push_job_log_jump_to_failures
push_frontend_feature_flag(:job_log_jump_to_failures, @project)
end
+
+ def push_ai_build_failure_cause
+ push_frontend_feature_flag(:ai_build_failure_cause, @project)
+ end
end
diff --git a/app/controllers/projects/merge_requests/conflicts_controller.rb b/app/controllers/projects/merge_requests/conflicts_controller.rb
index 76a233afa13..66a358963e2 100644
--- a/app/controllers/projects/merge_requests/conflicts_controller.rb
+++ b/app/controllers/projects/merge_requests/conflicts_controller.rb
@@ -15,7 +15,8 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap
respond_to do |format|
format.html do
@issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar')
- Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter.track_loading_conflict_ui_action(user: current_user)
+ Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter
+ .track_loading_conflict_ui_action(user: current_user)
end
format.json do
@@ -23,12 +24,14 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap
render json: @conflicts_list
elsif @merge_request.can_be_merged?
render json: {
- message: _('The merge conflicts for this merge request have already been resolved. Please return to the merge request.'),
+ message: _('The merge conflicts for this merge request have already been resolved. ' \
+ 'Please return to the merge request.'),
type: 'error'
}
else
render json: {
- message: _('The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.'),
+ message: _('The merge conflicts for this merge request cannot be resolved through GitLab. ' \
+ 'Please try to resolve them locally.'),
type: 'error'
}
end
@@ -52,7 +55,8 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap
Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter.track_resolve_conflict_action(user: current_user)
if @merge_request.can_be_merged?
- render status: :bad_request, json: { message: _('The merge conflicts for this merge request have already been resolved.') }
+ render status: :bad_request,
+ json: { message: _('The merge conflicts for this merge request have already been resolved.') }
return
end
@@ -71,6 +75,8 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap
private
+ alias_method :issuable, :merge_request
+
def authorize_can_resolve_conflicts!
@conflicts_list = ::MergeRequests::Conflicts::ListService.new(@merge_request)
diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
index 06381315614..6a3523b82d9 100644
--- a/app/controllers/projects/merge_requests/creations_controller.rb
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -11,6 +11,12 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
before_action :apply_diff_view_cookie!, only: [:diffs, :diff_for_path]
before_action :build_merge_request, except: [:create]
+ before_action only: [:new] do
+ if can?(current_user, :fill_in_merge_request_template, project)
+ push_frontend_feature_flag(:fill_in_mr_template, project)
+ end
+ end
+
urgency :low, [
:new,
:create,
@@ -25,7 +31,9 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
end
def create
- @merge_request = ::MergeRequests::CreateService.new(project: project, current_user: current_user, params: merge_request_params).execute
+ @merge_request = ::MergeRequests::CreateService
+ .new(project: project, current_user: current_user, params: merge_request_params)
+ .execute
if @merge_request.valid?
incr_count_webide_merge_request
@@ -82,7 +90,10 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
def branch_to
@target_project = selected_target_project
- if @target_project && params[:ref].present? && Ability.allowed?(current_user, :create_merge_request_in, @target_project)
+ if @target_project &&
+ params[:ref].present? &&
+ Ability.allowed?(current_user, :create_merge_request_in, @target_project)
+
@ref = params[:ref]
@commit = @target_project.commit(Gitlab::Git::BRANCH_REF_PREFIX + @ref)
end
@@ -104,10 +115,13 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
def build_merge_request
params[:merge_request] ||= ActionController::Parameters.new(source_project: @project)
+ new_params = merge_request_params.merge(diff_options: diff_options)
# Gitaly N+1 issue: https://gitlab.com/gitlab-org/gitlab-foss/issues/58096
Gitlab::GitalyClient.allow_n_plus_1_calls do
- @merge_request = ::MergeRequests::BuildService.new(project: project, current_user: current_user, params: merge_request_params.merge(diff_options: diff_options)).execute
+ @merge_request = ::MergeRequests::BuildService
+ .new(project: project, current_user: current_user, params: new_params)
+ .execute
end
end
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index f3a01fd3223..5bd0063ab95 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -49,7 +49,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
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.
+ # NOTE: Any variables that would affect the resulting json needs to be added to the cache_context
+ # to avoid stale cache issues.
cache_context = [
current_user&.cache_key,
unfoldable_positions.map(&:to_h),
@@ -130,7 +131,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
# rubocop: disable CodeReuse/ActiveRecord
def commit
return unless commit_id = params[:commit_id].presence
- return unless @merge_request.all_commits.exists?(sha: commit_id) || @merge_request.recent_context_commits.map(&:id).include?(commit_id)
+ return unless @merge_request.all_commits.exists?(sha: commit_id) ||
+ @merge_request.recent_context_commits.map(&:id).include?(commit_id)
@commit ||= @project.commit(commit_id)
end
@@ -160,7 +162,10 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
end
end
- return @merge_request.context_commits_diff if show_only_context_commits? && !@merge_request.context_commits_diff.empty?
+ if show_only_context_commits? && !@merge_request.context_commits_diff.empty?
+ return @merge_request.context_commits_diff
+ end
+
return @merge_request.merge_head_diff if render_merge_ref_head_diff?
if @start_sha
diff --git a/app/controllers/projects/merge_requests/drafts_controller.rb b/app/controllers/projects/merge_requests/drafts_controller.rb
index ca6ab83b877..74c495261a3 100644
--- a/app/controllers/projects/merge_requests/drafts_controller.rb
+++ b/app/controllers/projects/merge_requests/drafts_controller.rb
@@ -27,17 +27,23 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli
draft_note = create_service.execute
+ if draft_note.errors.present?
+ render json: { errors: draft_note.errors.full_messages.to_sentence }, status: :unprocessable_entity
+ return
+ end
+
prepare_notes_for_rendering(draft_note)
render json: DraftNoteSerializer.new(current_user: current_user).represent(draft_note)
end
def update
- draft_note.update!(draft_note_params)
-
- prepare_notes_for_rendering(draft_note)
-
- render json: DraftNoteSerializer.new(current_user: current_user).represent(draft_note)
+ if draft_note.update(draft_note_params)
+ prepare_notes_for_rendering(draft_note)
+ render json: DraftNoteSerializer.new(current_user: current_user).represent(draft_note)
+ else
+ render json: { errors: draft_note.errors.full_messages.to_sentence }, status: :unprocessable_entity
+ end
end
def destroy
@@ -57,10 +63,13 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli
if Gitlab::Utils.to_boolean(approve_params[:approve])
unless merge_request.approved_by?(current_user)
- success = ::MergeRequests::ApprovalService.new(project: @project, current_user: current_user, params: approve_params).execute(merge_request)
+ success = ::MergeRequests::ApprovalService
+ .new(project: @project, current_user: current_user, params: approve_params)
+ .execute(merge_request)
unless success
- return render json: { message: _('An error occurred while approving, please try again.') }, status: :internal_server_error
+ return render json: { message: _('An error occurred while approving, please try again.') },
+ status: :internal_server_error
end
end
@@ -101,7 +110,9 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli
# rubocop: disable CodeReuse/ActiveRecord
def merge_request
- @merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id])
+ @merge_request ||= MergeRequestsFinder
+ .new(current_user, project_id: @project.id)
+ .find_by!(iid: params[:merge_request_id])
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 60f619a8d20..2172c91fc76 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -41,19 +41,23 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_force_frontend_feature_flag(:content_editor_on_issues, project&.content_editor_on_issues_feature_flag_enabled?)
push_frontend_feature_flag(:core_security_mr_widget_counts, project)
push_frontend_feature_flag(:issue_assignees_widget, @project)
- push_frontend_feature_flag(:deprecate_vulnerabilities_feedback, @project)
push_frontend_feature_flag(:moved_mr_sidebar, project)
+ push_frontend_feature_flag(:sast_reports_in_inline_diff, project)
push_frontend_feature_flag(:mr_experience_survey, project)
push_frontend_feature_flag(:saved_replies, current_user)
push_frontend_feature_flag(:code_quality_inline_drawer, project)
- push_frontend_feature_flag(:auto_merge_labels_mr_widget, project)
push_force_frontend_feature_flag(:summarize_my_code_review, summarize_my_code_review_enabled?)
push_frontend_feature_flag(:mr_activity_filters, current_user)
push_frontend_feature_flag(:review_apps_redeploy_mr_widget, project)
- push_frontend_feature_flag(:comment_on_files, current_user)
push_frontend_feature_flag(:ci_job_failures_in_mr, project)
end
+ before_action only: [:edit] do
+ if can?(current_user, :fill_in_merge_request_template, project)
+ push_frontend_feature_flag(:fill_in_mr_template, project)
+ end
+ end
+
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :diffs, :discussions]
after_action :log_merge_request_show, only: [:show, :diffs]
@@ -124,16 +128,31 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@merge_request.recent_context_commits
)
- per_page = [(params[:per_page] || MergeRequestDiff::COMMITS_SAFE_SIZE).to_i, MergeRequestDiff::COMMITS_SAFE_SIZE].min
- recent_commits = @merge_request.recent_commits(load_from_gitaly: true, limit: per_page, page: params[:page]).with_latest_pipeline(@merge_request.source_branch).with_markdown_cache
+ per_page = [
+ (params[:per_page] || MergeRequestDiff::COMMITS_SAFE_SIZE).to_i,
+ MergeRequestDiff::COMMITS_SAFE_SIZE
+ ].min
+ recent_commits = @merge_request
+ .recent_commits(load_from_gitaly: true, limit: per_page, page: params[:page])
+ .with_latest_pipeline(@merge_request.source_branch)
+ .with_markdown_cache
@next_page = recent_commits.next_page
@commits = set_commits_for_rendering(
recent_commits,
commits_count: @merge_request.commits_count
)
- commits_count = @merge_request.preparing? ? '-' : @merge_request.commits_count + @merge_request.context_commits_count
- render json: { html: view_to_html_string('projects/merge_requests/_commits'), next_page: @next_page, count: commits_count }
+ commits_count = if @merge_request.preparing?
+ '-'
+ else
+ @merge_request.commits_count + @merge_request.context_commits_count
+ end
+
+ render json: {
+ html: view_to_html_string('projects/merge_requests/_commits'),
+ next_page: @next_page,
+ count: commits_count
+ }
end
def pipelines
@@ -221,7 +240,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def update
- @merge_request = ::MergeRequests::UpdateService.new(project: project, current_user: current_user, params: merge_request_update_params).execute(@merge_request)
+ @merge_request = ::MergeRequests::UpdateService
+ .new(project: project, current_user: current_user, params: merge_request_update_params)
+ .execute(@merge_request)
respond_to do |format|
format.html do
@@ -287,7 +308,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def assign_related_issues
- result = ::MergeRequests::AssignIssuesService.new(project: project, current_user: current_user, params: { merge_request: @merge_request }).execute
+ result = ::MergeRequests::AssignIssuesService
+ .new(project: project, current_user: current_user, params: { merge_request: @merge_request })
+ .execute
case result[:count]
when 0
@@ -317,7 +340,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def rebase
- @merge_request.rebase_async(current_user.id, skip_ci: Gitlab::Utils.to_boolean(merge_params[:skip_ci], default: false))
+ @merge_request
+ .rebase_async(current_user.id, skip_ci: Gitlab::Utils.to_boolean(merge_params[:skip_ci], default: false))
head :ok
rescue MergeRequest::RebaseLockTimeout => e
@@ -334,7 +358,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
IssuableExportCsvWorker.perform_async(:merge_request, current_user.id, project.id, finder_options.to_h) # rubocop:disable CodeReuse/Worker
index_path = project_merge_requests_path(project)
- message = _('Your CSV export has started. It will be emailed to %{email} when complete.') % { email: current_user.notification_email_or_default }
+ message = _('Your CSV export has started. It will be emailed to %{email} when complete.') %
+ { email: current_user.notification_email_or_default }
redirect_to(index_path, notice: message)
end
@@ -432,10 +457,15 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@commits_count = @merge_request.commits_count + @merge_request.context_commits_count
@diffs_count = get_diffs_count
@issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar')
- @current_user_data = Gitlab::Json.dump(UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestCurrentUserEntity))
+ @current_user_data = Gitlab::Json
+ .dump(UserSerializer.new(project: @project)
+ .represent(current_user, {}, MergeRequestCurrentUserEntity))
@show_whitespace_default = current_user.nil? || current_user.show_whitespace_in_diffs
@file_by_file_default = current_user&.view_diffs_file_by_file
- @coverage_path = coverage_reports_project_merge_request_path(@project, @merge_request, format: :json) if @merge_request.has_coverage_reports?
+ if @merge_request.has_coverage_reports?
+ @coverage_path = coverage_reports_project_merge_request_path(@project, @merge_request, format: :json)
+ end
+
@update_current_user_path = expose_path(api_v4_user_preferences_path)
@endpoint_metadata_url = endpoint_metadata_url(@project, @merge_request)
@endpoint_diff_batch_url = endpoint_diff_batch_url(@project, @merge_request)
@@ -478,12 +508,18 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
def merge!
# Disable the CI check if auto_merge_strategy is specified since we have
# to wait until CI completes to know
- unless @merge_request.mergeable?(skip_ci_check: auto_merge_requested?)
+ skipped_checks = @merge_request.skipped_mergeable_checks(
+ auto_merge_requested: auto_merge_requested?,
+ auto_merge_strategy: params[:auto_merge_strategy]
+ )
+
+ unless @merge_request.mergeable?(**skipped_checks)
return :failed
end
squashing = params.fetch(:squash, false)
- merge_service = ::MergeRequests::MergeService.new(project: @project, current_user: current_user, params: merge_params)
+ merge_service = ::MergeRequests::MergeService
+ .new(project: @project, current_user: current_user, params: merge_params)
unless merge_service.hooks_validation_pass?(@merge_request, validate_squash_message: squashing)
return :hook_validation_error
@@ -500,7 +536,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
AutoMergeService.new(project, current_user, merge_params).update(merge_request)
else
AutoMergeService.new(project, current_user, merge_params)
- .execute(merge_request, params[:auto_merge_strategy] || AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS)
+ .execute(
+ merge_request,
+ params[:auto_merge_strategy] || AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS
+ )
end
else
@merge_request.merge_async(current_user.id, merge_params)
@@ -595,7 +634,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
def endpoint_diff_batch_url(project, merge_request)
per_page = current_user&.view_diffs_file_by_file ? '1' : '5'
- params = request.query_parameters.merge(view: 'inline', diff_head: true, w: show_whitespace, page: '0', per_page: per_page)
+ params = request
+ .query_parameters
+ .merge(view: 'inline', diff_head: true, w: show_whitespace, page: '0', per_page: per_page)
params[:ck] = merge_request.merge_head_diff&.id if merge_request.diffs_batch_cache_with_max_age?
diffs_batch_project_json_merge_request_path(project, merge_request, 'json', params)
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index 35b65dbce7e..1f4e5b54500 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -24,6 +24,10 @@ class Projects::MilestonesController < Projects::ApplicationController
feature_category :team_planning
urgency :low
+ before_action do
+ push_frontend_feature_flag(:content_editor_on_issues, @project)
+ end
+
def index
@sort = params[:sort] || 'due_date_asc'
@milestones = milestones.sort_by_attribute(@sort)
diff --git a/app/controllers/projects/ml/candidates_controller.rb b/app/controllers/projects/ml/candidates_controller.rb
index ed7155fc5f4..9905e454acb 100644
--- a/app/controllers/projects/ml/candidates_controller.rb
+++ b/app/controllers/projects/ml/candidates_controller.rb
@@ -3,7 +3,9 @@
module Projects
module Ml
class CandidatesController < ApplicationController
- before_action :check_feature_enabled, :set_candidate
+ before_action :set_candidate
+ before_action :check_read, only: [:show]
+ before_action :check_write, only: [:destroy]
feature_category :mlops
@@ -26,9 +28,13 @@ module Projects
render_404 unless @candidate.present?
end
- def check_feature_enabled
+ def check_read
render_404 unless can?(current_user, :read_model_experiments, @project)
end
+
+ def check_write
+ render_404 unless can?(current_user, :write_model_experiments, @project)
+ end
end
end
end
diff --git a/app/controllers/projects/ml/experiments_controller.rb b/app/controllers/projects/ml/experiments_controller.rb
index a620e9919e7..85e7f63779c 100644
--- a/app/controllers/projects/ml/experiments_controller.rb
+++ b/app/controllers/projects/ml/experiments_controller.rb
@@ -5,7 +5,8 @@ module Projects
class ExperimentsController < ::Projects::ApplicationController
include Projects::Ml::ExperimentsHelper
- before_action :check_feature_enabled
+ before_action :check_read, only: [:show, :index]
+ before_action :check_write, only: [:destroy]
before_action :set_experiment, only: [:show, :destroy]
feature_category :mlops
@@ -55,10 +56,14 @@ module Projects
private
- def check_feature_enabled
+ def check_read
render_404 unless can?(current_user, :read_model_experiments, @project)
end
+ def check_write
+ render_404 unless can?(current_user, :write_model_experiments, @project)
+ end
+
def set_experiment
@experiment = ::Ml::Experiment.by_project_id_and_iid(@project.id, params[:iid])
diff --git a/app/controllers/projects/ml/models_controller.rb b/app/controllers/projects/ml/models_controller.rb
new file mode 100644
index 00000000000..77855b73cbd
--- /dev/null
+++ b/app/controllers/projects/ml/models_controller.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Projects
+ module Ml
+ class ModelsController < ::Projects::ApplicationController
+ before_action :check_feature_enabled
+ feature_category :mlops
+
+ def index
+ @models = ::Projects::Ml::ModelFinder.new(@project).execute
+ end
+
+ private
+
+ def check_feature_enabled
+ render_404 unless can?(current_user, :read_model_registry, @project)
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index 054e8c302c9..7fcdf220bd2 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class Projects::NotesController < Projects::ApplicationController
+ extend Gitlab::Utils::Override
include RendersNotes
include NotesActions
include NotesHelper
@@ -11,10 +12,30 @@ class Projects::NotesController < Projects::ApplicationController
before_action :authorize_create_note!, only: [:create]
before_action :authorize_resolve_note!, only: [:resolve, :unresolve]
- feature_category :team_planning
+ feature_category :team_planning, [:index, :create, :update, :destroy, :delete_attachment, :toggle_award_emoji]
+ feature_category :code_review_workflow, [:resolve, :unresolve, :outdated_line_change]
urgency :medium, [:index]
urgency :low, [:create, :update, :destroy, :resolve, :unresolve, :toggle_award_emoji, :outdated_line_change]
+ override :feature_category
+ def feature_category
+ if %w[index create].include?(params[:action])
+ category = feature_category_override_for_target_type(params[:target_type])
+ return category if category
+ end
+
+ super
+ end
+
+ def feature_category_override_for_target_type(target_type)
+ case target_type
+ when 'merge_request'
+ 'code_review_workflow'
+ when 'commit', 'project_snippet'
+ 'source_code_management'
+ end
+ end
+
def delete_attachment
note.remove_attachment!
note.update_attribute(:attachment, nil)
diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb
index 332d33b8e52..6cfbb61fbb2 100644
--- a/app/controllers/projects/pages_controller.rb
+++ b/app/controllers/projects/pages_controller.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
class Projects::PagesController < Projects::ApplicationController
- layout :resolve_layout
-
before_action :require_pages_enabled!
before_action :authorize_read_pages!, only: [:show]
before_action :authorize_update_pages!, except: [:show, :destroy]
@@ -10,10 +8,6 @@ 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,
@@ -66,10 +60,6 @@ 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/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
index fb332fec3b5..4fd307b5105 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -25,14 +25,25 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
end
def create
- @schedule = Ci::CreatePipelineScheduleService
- .new(@project, current_user, schedule_params)
- .execute
-
- if @schedule.persisted?
- redirect_to pipeline_schedules_path(@project)
+ if ::Feature.enabled?(:ci_refactoring_pipeline_schedule_create_service, @project)
+ response = Ci::PipelineSchedules::CreateService.new(@project, current_user, schedule_params).execute
+ @schedule = response.payload
+
+ if response.success?
+ redirect_to pipeline_schedules_path(@project)
+ else
+ render :new
+ end
else
- render :new
+ @schedule = Ci::CreatePipelineScheduleService
+ .new(@project, current_user, schedule_params)
+ .execute
+
+ if @schedule.persisted?
+ redirect_to pipeline_schedules_path(@project)
+ else
+ render :new
+ end
end
end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 98e6459b543..a96ee2215c2 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -22,7 +22,6 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action :ensure_pipeline, only: [:show, :downloadable_artifacts]
before_action :reject_if_build_artifacts_size_refreshing!, only: [:destroy]
- before_action :push_frontend_feature_flags, only: [:show, :builds, :dag, :failures, :test_report]
# Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596
before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? }
@@ -350,10 +349,6 @@ class Projects::PipelinesController < Projects::ApplicationController
def tracking_project_source
project
end
-
- def push_frontend_feature_flags
- push_frontend_feature_flag(:pipeline_details_header_vue, @project)
- end
end
Projects::PipelinesController.prepend_mod_with('Projects::PipelinesController')
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index 2b2c2cef8e2..db19ca23e9f 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -12,8 +12,7 @@ class Projects::RunnersController < Projects::ApplicationController
redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings')
end
- def edit
- end
+ def edit; end
def update
if Ci::Runners::UpdateRunnerService.new(@runner).execute(runner_params).success?
@@ -23,12 +22,10 @@ class Projects::RunnersController < Projects::ApplicationController
end
end
- def new
- render_404 unless create_runner_workflow_for_namespace_enabled?
- end
+ def new; end
def register
- render_404 unless create_runner_workflow_for_namespace_enabled? && runner.registration_available?
+ render_404 unless runner.registration_available?
end
def destroy
@@ -55,8 +52,7 @@ class Projects::RunnersController < Projects::ApplicationController
end
end
- def show
- end
+ def show; end
def toggle_shared_runners
update_params = { shared_runners_enabled: !project.shared_runners_enabled }
@@ -84,8 +80,4 @@ class Projects::RunnersController < Projects::ApplicationController
def runner_params
params.require(:runner).permit(Ci::Runner::FORM_EDITABLE)
end
-
- def create_runner_workflow_for_namespace_enabled?
- Feature.enabled?(:create_runner_workflow_for_namespace, project.namespace)
- end
end
diff --git a/app/controllers/projects/service_desk/custom_email_controller.rb b/app/controllers/projects/service_desk/custom_email_controller.rb
new file mode 100644
index 00000000000..fb5e87f9a97
--- /dev/null
+++ b/app/controllers/projects/service_desk/custom_email_controller.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+module Projects
+ module ServiceDesk
+ class CustomEmailController < Projects::ApplicationController
+ before_action :check_feature_flag_enabled
+ before_action :authorize_admin_project!
+
+ feature_category :service_desk
+ urgency :low
+
+ def create
+ response = ::ServiceDesk::CustomEmails::CreateService.new(
+ project: project,
+ current_user: current_user,
+ params: params
+ ).execute
+
+ json_response(service_response: response)
+ end
+
+ def update
+ response = ServiceDeskSettings::UpdateService.new(project, current_user, update_setting_params).execute
+
+ if response.error?
+ json_response(
+ error_message: s_("ServiceDesk|Cannot update custom email"),
+ status: :unprocessable_entity
+ )
+ return
+ end
+
+ json_response
+ end
+
+ def destroy
+ response = ::ServiceDesk::CustomEmails::DestroyService.new(
+ project: project,
+ current_user: current_user
+ ).execute
+
+ json_response(service_response: response)
+ end
+
+ def show
+ json_response
+ end
+
+ private
+
+ def update_setting_params
+ params.permit(:custom_email_enabled)
+ end
+
+ def json_response(error_message: nil, status: :ok, service_response: nil)
+ if service_response.present?
+ status = service_response.success? ? :ok : :unprocessable_entity
+ error_message = service_response.message
+ end
+
+ respond_to do |format|
+ format.json { render json: custom_email_attributes(error_message: error_message), status: status }
+ end
+ end
+
+ def custom_email_attributes(error_message:)
+ setting = project.service_desk_setting
+
+ {
+ custom_email: setting&.custom_email,
+ custom_email_enabled: setting&.custom_email_enabled || false,
+ custom_email_verification_state: setting&.custom_email_verification&.state,
+ custom_email_verification_error: setting&.custom_email_verification&.error,
+ custom_email_smtp_address: setting&.custom_email_credential&.smtp_address,
+ error_message: error_message
+ }
+ end
+
+ def check_feature_flag_enabled
+ render_404 unless Feature.enabled?(:service_desk_custom_email, @project)
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/service_desk_controller.rb b/app/controllers/projects/service_desk_controller.rb
index 8f576b8d72b..b1e30e7a45b 100644
--- a/app/controllers/projects/service_desk_controller.rb
+++ b/app/controllers/projects/service_desk_controller.rb
@@ -13,12 +13,12 @@ class Projects::ServiceDeskController < Projects::ApplicationController
def update
Projects::UpdateService.new(project, current_user, { service_desk_enabled: params[:service_desk_enabled] }).execute
- result = ServiceDeskSettings::UpdateService.new(project, current_user, setting_params).execute
+ response = ServiceDeskSettings::UpdateService.new(project, current_user, setting_params).execute
- if result[:status] == :success
+ if response.success?
json_response
else
- render json: { message: result[:message] }, status: :unprocessable_entity
+ render json: { message: response.message }, status: :unprocessable_entity
end
end
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index ce760051f79..0e892ef3faa 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -14,10 +14,6 @@ module Projects
before_action do
push_frontend_feature_flag(:ci_variables_pages, current_user)
- push_frontend_feature_flag(:ci_limit_environment_scope, @project)
- push_frontend_feature_flag(:create_runner_workflow_for_namespace, @project.namespace)
- push_frontend_feature_flag(:frozen_outbound_job_token_scopes, @project)
- push_frontend_feature_flag(:frozen_outbound_job_token_scopes_override, @project)
end
helper_method :highlight_badge
diff --git a/app/controllers/projects/tracing_controller.rb b/app/controllers/projects/tracing_controller.rb
new file mode 100644
index 00000000000..d1218ebf344
--- /dev/null
+++ b/app/controllers/projects/tracing_controller.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Projects
+ class TracingController < Projects::ApplicationController
+ include ::Observability::ContentSecurityPolicy
+
+ feature_category :tracing
+
+ before_action :check_tracing_enabled
+
+ def index; end
+
+ private
+
+ def check_tracing_enabled
+ render_404 unless Gitlab::Observability.tracing_enabled?(project)
+ end
+ end
+end
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index c8f698d6193..b961339111b 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -12,12 +12,14 @@ class Projects::TreeController < Projects::ApplicationController
before_action :require_non_empty_project, except: [:new, :create]
before_action :assign_ref_vars
+ before_action :find_requested_ref, only: [:show]
before_action :assign_dir_vars, only: [:create_dir]
before_action :authorize_read_code!
before_action :authorize_edit_tree!, only: [:create_dir]
before_action do
push_frontend_feature_flag(:highlight_js, @project)
+ push_frontend_feature_flag(:highlight_js_worker, @project)
push_frontend_feature_flag(:explain_code_chat, current_user)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end
@@ -28,18 +30,20 @@ class Projects::TreeController < Projects::ApplicationController
def show
return render_404 unless @commit
- @ref_type = ref_type
- if @ref_type == BRANCH_REF_TYPE && ambiguous_ref?(@project, @ref)
- branch = @project.repository.find_branch(@ref)
- if branch
- redirect_to project_tree_path(@project, branch.target)
- return
+ unless Feature.enabled?(:redirect_with_ref_type, @project)
+ @ref_type = ref_type
+ if @ref_type == BRANCH_REF_TYPE && ambiguous_ref?(@project, @ref)
+ branch = @project.repository.find_branch(@ref)
+ if branch
+ redirect_to project_tree_path(@project, branch.target)
+ return
+ end
end
end
if tree.entries.empty?
if @repository.blob_at(@commit.id, @path)
- redirect_to project_blob_path(@project, File.join(@ref, @path))
+ redirect_to project_blob_path(@project, File.join(@ref, @path), ref_type: @ref_type)
elsif @path.present?
redirect_to_tree_root_for_missing_path(@project, @ref, @path)
end
@@ -59,6 +63,23 @@ class Projects::TreeController < Projects::ApplicationController
private
+ def find_requested_ref
+ return unless Feature.enabled?(:redirect_with_ref_type, @project)
+
+ @ref_type = ref_type
+ if @ref_type.present?
+ @tree = @repo.tree(@ref, @path, ref_type: @ref_type)
+ else
+ response = ExtractsPath::RequestedRef.new(@repository, ref_type: nil, ref: @ref).find
+ @ref_type = response[:ref_type]
+ @commit = response[:commit]
+
+ if response[:ambiguous]
+ redirect_to(project_tree_path(@project, File.join(@ref_type ? @ref : @commit.id, @path), ref_type: @ref_type))
+ end
+ end
+ end
+
def redirect_renamed_default_branch?
action_name == 'show'
end
diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb
index 8f4987a07f6..48399e17b25 100644
--- a/app/controllers/projects/uploads_controller.rb
+++ b/app/controllers/projects/uploads_controller.rb
@@ -11,7 +11,7 @@ class Projects::UploadsController < Projects::ApplicationController
before_action :authorize_upload_file!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]
- feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
+ feature_category :team_planning
private
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 81f205a6457..51f6158d9c0 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -38,9 +38,11 @@ class ProjectsController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:highlight_js, @project)
+ push_frontend_feature_flag(:highlight_js_worker, @project)
push_frontend_feature_flag(:remove_monitor_metrics, @project)
push_frontend_feature_flag(:explain_code_chat, current_user)
push_frontend_feature_flag(:ci_namespace_catalog_experimental, @project)
+ push_frontend_feature_flag(:service_desk_custom_email, @project)
push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks)
push_licensed_feature(:security_orchestration_policies) if @project.present? && @project.licensed_feature_available?(:security_orchestration_policies)
push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?)
@@ -172,7 +174,9 @@ class ProjectsController < Projects::ApplicationController
flash.now[:alert] = _("Project '%{project_name}' queued for deletion.") % { project_name: @project.name }
end
- if ambiguous_ref?(@project, @ref)
+ if Feature.enabled?(:redirect_with_ref_type, @project)
+ @ref_type = 'heads'
+ elsif ambiguous_ref?(@project, @ref)
branch = @project.repository.find_branch(@ref)
# The files view would render a ref other than the default branch
diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb
index 76aa4afbe80..76f181e3ce8 100644
--- a/app/controllers/registrations/welcome_controller.rb
+++ b/app/controllers/registrations/welcome_controller.rb
@@ -7,10 +7,10 @@ module Registrations
include ::Gitlab::Utils::StrongMemoize
layout 'minimal'
- skip_before_action :authenticate_user!, :required_signup_info, :check_two_factor_requirement, only: [:show, :update]
- before_action :require_current_user
+ skip_before_action :required_signup_info, :check_two_factor_requirement
helper_method :welcome_update_params
+ helper_method :onboarding_status
feature_category :user_management
@@ -25,7 +25,7 @@ module Registrations
if result.success?
track_event('successfully_submitted_form')
- finish_onboarding_on_welcome_page unless complete_signup_onboarding?
+ successful_update_hooks
redirect_to update_success_path
else
@@ -35,14 +35,10 @@ module Registrations
private
- def registering_from_invite?(members)
- # If there are more than one member it will mean we have been invited to multiple projects/groups and
- # are not able to distinguish which one we should putting the user in after registration
- members.count == 1 && members.last.source.present?
- end
+ def authenticate_user!
+ return if current_user
- def require_current_user
- return redirect_to new_user_registration_path unless current_user
+ redirect_to new_user_registration_path
end
def completed_welcome_step?
@@ -54,33 +50,28 @@ module Registrations
end
def path_for_signed_in_user(user)
- stored_location_for(user) || members_activity_path(user.members)
- end
-
- def members_activity_path(members)
- return dashboard_projects_path unless members.any?
- return dashboard_projects_path unless members.last.source.present?
-
- members.last.source.activity_path
+ stored_location_for(user) || last_member_activity_path
end
# overridden in EE
def complete_signup_onboarding?
- false
+ onboarding_status.continue_full_onboarding?
end
- def invites_with_tasks_to_be_done?
- MemberTask.for_members(user_members).exists?
+ def last_member_activity_path
+ return dashboard_projects_path unless onboarding_status.last_invited_member_source.present?
+
+ onboarding_status.last_invited_member_source.activity_path
end
def update_success_path
- if invites_with_tasks_to_be_done?
+ if onboarding_status.invite_with_tasks_to_be_done?
issues_dashboard_path(assignee_username: current_user.username)
elsif complete_signup_onboarding? # trials/regular registration on .com
signup_onboarding_path
- elsif registering_from_invite?(user_members) # invites w/o tasks due to order
- flash[:notice] = helpers.invite_accepted_notice(user_members.last)
- members_activity_path(user_members)
+ elsif onboarding_status.single_invite? # invites w/o tasks due to order
+ flash[:notice] = helpers.invite_accepted_notice(onboarding_status.last_invited_member)
+ onboarding_status.last_invited_member_source.activity_path
else
# Subscription registrations goes through here as well.
# Invites will come here too if there is more than 1.
@@ -88,13 +79,8 @@ module Registrations
end
end
- def user_members
- current_user.members
- end
- strong_memoize_attr :user_members
-
# overridden in EE
- def finish_onboarding_on_welcome_page; end
+ def successful_update_hooks; end
# overridden in EE
def signup_onboarding_path; end
@@ -106,6 +92,11 @@ module Registrations
def welcome_update_params
{}
end
+
+ def onboarding_status
+ Onboarding::Status.new(current_user)
+ end
+ strong_memoize_attr :onboarding_status
end
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index f481681da02..76b7d30cd51 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -184,8 +184,6 @@ class RegistrationsController < Devise::RegistrationsController
end
def check_captcha
- ensure_correct_params!
-
return unless show_recaptcha_sign_up?
return unless Gitlab::Recaptcha.load_configurations!
@@ -224,6 +222,7 @@ class RegistrationsController < Devise::RegistrationsController
end
def sign_up_params
+ ensure_correct_params!
params.require(:user).permit(sign_up_params_attributes)
end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index b797a204d7f..6d3811514d9 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -29,7 +29,7 @@ class UploadsController < ApplicationController
before_action :authorize_create_access!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]
- feature_category :team_planning
+ feature_category :groups_and_projects
def self.model_classes
MODEL_CLASSES
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 4db5745c005..88a8851607b 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -97,11 +97,11 @@ class UsersController < ApplicationController
end
def groups
- load_groups
-
respond_to do |format|
format.html { render 'show' }
format.json do
+ load_groups
+
render json: {
html: view_to_html_string("shared/groups/_list", groups: @groups)
}
@@ -110,36 +110,36 @@ class UsersController < ApplicationController
end
def projects
- load_projects
-
- present_projects(@projects)
+ present_projects do
+ load_projects
+ end
end
def contributed
- load_contributed_projects
-
- present_projects(@contributed_projects)
+ present_projects do
+ load_contributed_projects
+ end
end
def starred
- load_starred_projects
-
- present_projects(@starred_projects)
+ present_projects do
+ load_starred_projects
+ end
end
def followers
- @user_followers = user.followers.page(params[:page])
-
- present_users(@user_followers)
+ present_users do
+ @user_followers = user.followers.page(params[:page])
+ end
end
def following
- @user_following = user.followees.page(params[:page])
-
- present_users(@user_following)
+ present_users do
+ @user_following = user.followees.page(params[:page])
+ end
end
- def present_projects(projects)
+ def present_projects
skip_pagination = Gitlab::Utils.to_boolean(params[:skip_pagination])
skip_namespace = Gitlab::Utils.to_boolean(params[:skip_namespace])
compact_mode = Gitlab::Utils.to_boolean(params[:compact_mode])
@@ -147,17 +147,19 @@ class UsersController < ApplicationController
respond_to do |format|
format.html { render 'show' }
format.json do
+ projects = yield
+
pager_json("shared/projects/_list", projects.count, projects: projects, skip_pagination: skip_pagination, skip_namespace: skip_namespace, compact_mode: compact_mode)
end
end
end
def snippets
- load_snippets
-
respond_to do |format|
format.html { render 'show' }
format.json do
+ load_snippets
+
render json: {
html: view_to_html_string("snippets/_snippets", collection: @snippets)
}
@@ -281,10 +283,11 @@ class UsersController < ApplicationController
access_denied! unless can?(current_user, :read_user_profile, user)
end
- def present_users(users)
+ def present_users
respond_to do |format|
format.html { render 'show' }
format.json do
+ users = yield
render json: {
html: view_to_html_string("shared/users/index", users: users)
}
diff --git a/app/experiments/concerns/project_commit_count.rb b/app/experiments/concerns/project_commit_count.rb
deleted file mode 100644
index 3f08538c21f..00000000000
--- a/app/experiments/concerns/project_commit_count.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-module ProjectCommitCount
- include Gitlab::Git::WrapsGitalyErrors
-
- def commit_count_for(project, default_count: 0, max_count: nil, **exception_details)
- raw_repo = project.repository&.raw_repository
- root_ref = raw_repo&.root_ref
-
- return default_count unless root_ref
-
- Gitlab::GitalyClient::CommitService.new(raw_repo).commit_count(root_ref, {
- all: true, # include all branches
- max_count: max_count # limit as an optimization
- })
- rescue StandardError => e
- Gitlab::ErrorTracking.track_exception(e, exception_details)
-
- default_count
- end
-end
diff --git a/app/experiments/empty_repo_upload_experiment.rb b/app/experiments/empty_repo_upload_experiment.rb
deleted file mode 100644
index c8c75f32d69..00000000000
--- a/app/experiments/empty_repo_upload_experiment.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-class EmptyRepoUploadExperiment < ApplicationExperiment
- include ProjectCommitCount
-
- TRACKING_START_DATE = DateTime.parse('2021/4/20')
- INITIAL_COMMIT_COUNT = 1
-
- def track_initial_write
- return unless should_track? # early return if we don't need to ask for commit counts
- return unless context.project.created_at > TRACKING_START_DATE # early return for older projects
- return unless commit_count == INITIAL_COMMIT_COUNT
-
- track(:initial_write, project: context.project)
- end
-
- private
-
- def commit_count
- commit_count_for(context.project, max_count: INITIAL_COMMIT_COUNT, experiment: name)
- end
-end
diff --git a/app/experiments/force_company_trial_experiment.rb b/app/experiments/force_company_trial_experiment.rb
deleted file mode 100644
index e7b98bb18ad..00000000000
--- a/app/experiments/force_company_trial_experiment.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-class ForceCompanyTrialExperiment < ApplicationExperiment
- exclude :setup_for_personal
-
- private
-
- def setup_for_personal
- !context.user.setup_for_company
- end
-end
diff --git a/app/experiments/logged_out_marketing_header_experiment.rb b/app/experiments/logged_out_marketing_header_experiment.rb
deleted file mode 100644
index 3d88d94aec4..00000000000
--- a/app/experiments/logged_out_marketing_header_experiment.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-class LoggedOutMarketingHeaderExperiment < ApplicationExperiment
- # These default behaviors are overriden in ApplicationHelper and header
- # template partial
- control {}
- candidate {}
- variant(:trial_focused) {}
-end
diff --git a/app/finders/award_emojis_finder.rb b/app/finders/award_emojis_finder.rb
index 709d3f3e593..2f34e6ec4d3 100644
--- a/app/finders/award_emojis_finder.rb
+++ b/app/finders/award_emojis_finder.rb
@@ -33,7 +33,7 @@ class AwardEmojisFinder
def validate_params
return unless params.present?
- validate_name_param
+ validate_name_param unless Feature.enabled?(:custom_emoji)
validate_awarded_by_param
end
diff --git a/app/finders/ci/group_variables_finder.rb b/app/finders/ci/group_variables_finder.rb
new file mode 100644
index 00000000000..e4697b07e64
--- /dev/null
+++ b/app/finders/ci/group_variables_finder.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Ci
+ class GroupVariablesFinder
+ def initialize(project, sort_key = nil)
+ @project = project
+ @params = sort_to_params_map(sort_key)
+ end
+
+ def execute
+ variables = ::Ci::GroupVariable.for_groups(project.group&.self_and_ancestor_ids)
+
+ return Ci::GroupVariable.none if variables.empty?
+
+ sort(variables)
+ end
+
+ private
+
+ SORT_TO_PARAMS_MAP = {
+ created_desc: { order_by: 'created_at', sort: 'desc' },
+ created_asc: { order_by: 'created_at', sort: 'asc' },
+ key_desc: { order_by: 'key', sort: 'desc' },
+ key_asc: { order_by: 'key', sort: 'asc' }
+ }.freeze
+
+ def sort_to_params_map(sort_key)
+ SORT_TO_PARAMS_MAP[sort_key] || {}
+ end
+
+ def sort(variables)
+ return variables unless params[:order_by]
+
+ variables.sort_by_attribute("#{params[:order_by]}_#{params[:sort]}")
+ end
+
+ attr_reader :project, :params
+ end
+end
diff --git a/app/finders/ci/pipelines_finder.rb b/app/finders/ci/pipelines_finder.rb
index e52fc510628..6ba2ae91d6c 100644
--- a/app/finders/ci/pipelines_finder.rb
+++ b/app/finders/ci/pipelines_finder.rb
@@ -164,7 +164,7 @@ module Ci
:id
end
- sort = if params[:sort] =~ /\A(ASC|DESC)\z/i
+ sort = if /\A(ASC|DESC)\z/i.match?(params[:sort])
params[:sort]
else
:desc
diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb
index 5f03ae77338..630be17e64b 100644
--- a/app/finders/ci/runners_finder.rb
+++ b/app/finders/ci/runners_finder.rb
@@ -4,7 +4,6 @@ module Ci
class RunnersFinder < UnionFinder
include Gitlab::Allowable
- ALLOWED_SORTS = %w[contacted_asc contacted_desc created_at_asc created_at_desc created_date token_expires_at_asc token_expires_at_desc].freeze
DEFAULT_SORT = 'created_at_desc'
def initialize(current_user:, params:)
@@ -31,11 +30,17 @@ module Ci
end
def sort_key
- ALLOWED_SORTS.include?(@params[:sort]) ? @params[:sort] : DEFAULT_SORT
+ allowed_sorts.include?(@params[:sort]) ? @params[:sort] : DEFAULT_SORT
end
private
+ attr_reader :group, :project
+
+ def allowed_sorts
+ %w[contacted_asc contacted_desc created_at_asc created_at_desc created_date token_expires_at_asc token_expires_at_desc]
+ end
+
def search!
if @project
project_runners
@@ -128,3 +133,5 @@ module Ci
end
end
end
+
+Ci::RunnersFinder.prepend_mod
diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb
index 5241a3b3907..800158dfd0a 100644
--- a/app/finders/deployments_finder.rb
+++ b/app/finders/deployments_finder.rb
@@ -128,6 +128,7 @@ class DeploymentsFinder
def build_sort_params
order_by = ALLOWED_SORT_VALUES.include?(params[:order_by]) ? params[:order_by] : DEFAULT_SORT_VALUE
+ order_by = DEFAULT_SORT_VALUE if order_by == 'ref' && Feature.enabled?(:remove_deployments_api_ref_sort)
order_direction = ALLOWED_SORT_DIRECTIONS.include?(params[:sort]) ? params[:sort] : DEFAULT_SORT_DIRECTION
{ order_by => order_direction }
diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb
index 4ed447a90ce..7bccfe453ab 100644
--- a/app/finders/events_finder.rb
+++ b/app/finders/events_finder.rb
@@ -61,6 +61,7 @@ class EventsFinder
def by_current_user_access(events)
events.merge(Project.public_or_visible_to_user(current_user))
.joins(:project)
+ .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417462")
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb
index 07f39f98b12..72ab30cf567 100644
--- a/app/finders/group_descendants_finder.rb
+++ b/app/finders/group_descendants_finder.rb
@@ -109,11 +109,7 @@ class GroupDescendantsFinder
group_ids = base_for_ancestors.except(:select, :sort).select(:id)
groups = Group.where(id: group_ids)
- if Feature.enabled?(:linear_group_descendants_finder_upto, current_user)
- groups.self_and_ancestors(upto: parent_group.id)
- else
- Gitlab::ObjectHierarchy.new(groups).base_and_ancestors(upto: parent_group.id)
- end
+ groups.self_and_ancestors(upto: parent_group.id)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb
index 00b700a101e..db8a0f14fbc 100644
--- a/app/finders/group_projects_finder.rb
+++ b/app/finders/group_projects_finder.rb
@@ -24,6 +24,7 @@
# with_issues_enabled: boolean
# with_merge_requests_enabled: boolean
# min_access_level: int
+# owned: boolean
#
class GroupProjectsFinder < ProjectsFinder
DEFAULT_PROJECTS_LIMIT = 100
@@ -83,7 +84,9 @@ class GroupProjectsFinder < ProjectsFinder
def filter_by_visibility(relation)
if current_user
- if min_access_level?
+ if owned_projects?
+ relation.visible_to_user_and_access_level(current_user, Gitlab::Access::OWNER)
+ elsif min_access_level?
relation.visible_to_user_and_access_level(current_user, params[:min_access_level])
else
relation.public_or_visible_to_user(current_user)
@@ -105,6 +108,10 @@ class GroupProjectsFinder < ProjectsFinder
options.fetch(:only_owned, false)
end
+ def owned_projects?
+ params.fetch(:owned, false)
+ end
+
def only_shared?
options.fetch(:only_shared, false)
end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 478a2ba622c..bbbf14bb0d0 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -346,8 +346,7 @@ class IssuableFinder
def use_full_text_search?
klass.try(:pg_full_text_searchable_columns).present? &&
- params[:search] =~ FULL_TEXT_SEARCH_TERM_REGEX &&
- Feature.enabled?(:issues_full_text_search, params.project || params.group)
+ params[:search] =~ FULL_TEXT_SEARCH_TERM_REGEX
end
def filter_by_full_text_search(items)
diff --git a/app/finders/issuables/assignee_filter.rb b/app/finders/issuables/assignee_filter.rb
index 2e58a6b34c9..c97fdffd32e 100644
--- a/app/finders/issuables/assignee_filter.rb
+++ b/app/finders/issuables/assignee_filter.rb
@@ -5,6 +5,8 @@ module Issuables
def filter(issuables)
filtered = by_assignee(issuables)
filtered = by_assignee_union(filtered)
+ # Cross Joins Fails tests in bin/rspec spec/requests/api/graphql/boards/board_list_issues_query_spec.rb
+ filtered = filtered.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417462")
by_negated_assignee(filtered)
end
diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb
index 3c0714441b2..6348bceb157 100644
--- a/app/finders/members_finder.rb
+++ b/app/finders/members_finder.rb
@@ -90,8 +90,10 @@ class MembersFinder
# enumerate the columns here since we are enumerating them in the union and want to be immune to
# column caching issues when adding/removing columns
- Member.select(*Member.column_names)
+ members = Member.select(*Member.column_names)
.includes(:user).from([Arel.sql("(#{sql}) AS #{Member.table_name}")]) # rubocop: disable CodeReuse/ActiveRecord
+ # The left join with the table users in the method distinct_on needs to be resolved
+ members.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417456")
end
def distinct_on(union)
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index f7ee90ab870..95b5b267089 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -73,6 +73,7 @@ class MergeRequestsFinder < IssuableFinder
items = by_deployments(items)
items = by_reviewer(items)
items = by_source_project_id(items)
+ items = items.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417462")
by_approved(items)
end
diff --git a/app/finders/packages/ml_model/package_finder.rb b/app/finders/packages/ml_model/package_finder.rb
new file mode 100644
index 00000000000..a550ad0fa34
--- /dev/null
+++ b/app/finders/packages/ml_model/package_finder.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Packages
+ module MlModel
+ class PackageFinder
+ def initialize(project)
+ @project = project
+ end
+
+ def execute!(package_name, package_version)
+ project
+ .packages
+ .installable
+ .ml_model
+ .by_name_and_version!(package_name, package_version)
+ end
+
+ private
+
+ attr_reader :project
+ end
+ end
+end
diff --git a/app/finders/packages/npm/package_finder.rb b/app/finders/packages/npm/package_finder.rb
index 953e8299138..339b3f531c6 100644
--- a/app/finders/packages/npm/package_finder.rb
+++ b/app/finders/packages/npm/package_finder.rb
@@ -5,27 +5,16 @@ module Packages
delegate :find_by_version, to: :execute
delegate :last, to: :execute
- # /!\ CAUTION: don't use last_of_each_version: false with find_by_version. Ordering is not
- # guaranteed!
- def initialize(package_name, project: nil, namespace: nil, last_of_each_version: true)
+ def initialize(package_name, project: nil, namespace: nil)
@package_name = package_name
@project = project
@namespace = namespace
- @last_of_each_version = last_of_each_version
end
def execute
- result = base.npm
- .with_name(@package_name)
- .installable
-
- return result unless @last_of_each_version
-
- if Feature.enabled?(:npm_allow_packages_in_multiple_projects)
- Packages::Package.id_in(result.last_of_each_version_ids)
- else
- result.last_of_each_version
- end
+ base.npm
+ .with_name(@package_name)
+ .installable
end
private
diff --git a/app/finders/projects/ml/model_finder.rb b/app/finders/projects/ml/model_finder.rb
new file mode 100644
index 00000000000..9ef5dacb551
--- /dev/null
+++ b/app/finders/projects/ml/model_finder.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Projects
+ module Ml
+ class ModelFinder
+ def initialize(project)
+ @project = project
+ end
+
+ def execute
+ @project
+ .packages
+ .installable
+ .ml_model
+ .order_name_desc_version_desc
+ .select_only_first_by_name
+ .limit(100) # This is a temporary limit before we add pagination
+ end
+ end
+ end
+end
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 57a9538db15..e6ee4355fd4 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -53,6 +53,10 @@ class ProjectsFinder < UnionFinder
init_collection
end
+ if Feature.enabled?(:hide_projects_of_banned_users)
+ collection = without_created_and_owned_by_banned_user(collection)
+ end
+
use_cte = params.delete(:use_cte)
collection = Project.wrap_with_cte(collection) if use_cte
collection = filter_projects(collection)
@@ -282,6 +286,12 @@ class ProjectsFinder < UnionFinder
{ min_access_level: params[:min_access_level] }
end
+
+ def without_created_and_owned_by_banned_user(projects)
+ return projects if current_user&.can?(:admin_all_resources)
+
+ projects.without_created_and_owned_by_banned_user
+ end
end
ProjectsFinder.prepend_mod_with('ProjectsFinder')
diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb
index 57dbeca5c51..88ba635e20b 100644
--- a/app/finders/users_finder.rb
+++ b/app/finders/users_finder.rb
@@ -80,15 +80,11 @@ class UsersFinder
def by_search(users)
return users unless params[:search].present?
- if Feature.enabled?(:autocomplete_users_use_search_service)
- users.search(
- params[:search],
- with_private_emails: current_user&.can_admin_all_resources?,
- use_minimum_char_limit: params[:use_minimum_char_limit]
- )
- else
- users.search(params[:search], with_private_emails: current_user&.can_admin_all_resources?)
- end
+ users.search(
+ params[:search],
+ with_private_emails: current_user&.can_admin_all_resources?,
+ use_minimum_char_limit: params[:use_minimum_char_limit]
+ )
end
def by_blocked(users)
@@ -103,13 +99,11 @@ class UsersFinder
users.active
end
- # rubocop: disable CodeReuse/ActiveRecord
def by_external_identity(users)
- return users unless current_user&.can_admin_all_resources? && params[:extern_uid] && params[:provider]
+ return users unless params[:extern_uid] && params[:provider]
- users.joins(:identities).merge(Identity.with_extern_uid(params[:provider], params[:extern_uid]))
+ users.by_provider_and_extern_uid(params[:provider], params[:extern_uid])
end
- # rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def by_external(users)
diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb
index eed7959a2f1..0c7195c5be3 100644
--- a/app/graphql/gitlab_schema.rb
+++ b/app/graphql/gitlab_schema.rb
@@ -15,9 +15,6 @@ class GitlabSchema < GraphQL::Schema
use Gitlab::Graphql::Tracers::MetricsTracer
use Gitlab::Graphql::Tracers::LoggerTracer
- # TODO: Old tracer which will be removed eventually
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/345396
- use Gitlab::Graphql::GenericTracing
use Gitlab::Graphql::Tracers::TimerTracer
use Gitlab::Graphql::Subscriptions::ActionCableWithLoadBalancing
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/create.rb b/app/graphql/mutations/alert_management/prometheus_integration/create.rb
index 9c3aefce033..b06a4f58df5 100644
--- a/app/graphql/mutations/alert_management/prometheus_integration/create.rb
+++ b/app/graphql/mutations/alert_management/prometheus_integration/create.rb
@@ -17,7 +17,7 @@ module Mutations
description: 'Whether the integration is receiving alerts.'
argument :api_url, GraphQL::Types::String,
- required: true,
+ required: false,
description: 'Endpoint at which Prometheus can be queried.'
def resolve(args)
diff --git a/app/graphql/mutations/ci/job_token_scope/add_project.rb b/app/graphql/mutations/ci/job_token_scope/add_project.rb
index 6071d6750c2..0358bb11c58 100644
--- a/app/graphql/mutations/ci/job_token_scope/add_project.rb
+++ b/app/graphql/mutations/ci/job_token_scope/add_project.rb
@@ -35,14 +35,13 @@ module Mutations
def resolve(project_path:, target_project_path:, direction: nil)
project = authorized_find!(project_path)
target_project = Project.find_by_full_path(target_project_path)
- frozen_outbound = project.frozen_outbound_job_token_scopes?
- if direction == :outbound && frozen_outbound
+ if direction == :outbound
raise Gitlab::Graphql::Errors::ArgumentError, 'direction: OUTBOUND scope entries can only be removed. ' \
'Only INBOUND scope can be expanded.'
end
- direction ||= frozen_outbound ? :inbound : :outbound
+ direction ||= :inbound
result = ::Ci::JobTokenScope::AddProjectService
.new(project, current_user)
diff --git a/app/graphql/mutations/ci/pipeline_schedule/create.rb b/app/graphql/mutations/ci/pipeline_schedule/create.rb
index 65b355cd80f..71a366ed342 100644
--- a/app/graphql/mutations/ci/pipeline_schedule/create.rb
+++ b/app/graphql/mutations/ci/pipeline_schedule/create.rb
@@ -51,14 +51,28 @@ module Mutations
params = pipeline_schedule_attrs.merge(variables_attributes: variables.map(&:to_h))
- schedule = ::Ci::CreatePipelineScheduleService
- .new(project, current_user, params)
- .execute
-
- unless schedule.persisted?
- return {
- pipeline_schedule: nil, errors: schedule.errors.full_messages
- }
+ if ::Feature.enabled?(:ci_refactoring_pipeline_schedule_create_service, project)
+ response = ::Ci::PipelineSchedules::CreateService
+ .new(project, current_user, params)
+ .execute
+
+ schedule = response.payload
+
+ unless response.success?
+ return {
+ pipeline_schedule: nil, errors: response.errors
+ }
+ end
+ else
+ schedule = ::Ci::CreatePipelineScheduleService
+ .new(project, current_user, params)
+ .execute
+
+ unless schedule.persisted?
+ return {
+ pipeline_schedule: nil, errors: schedule.errors.full_messages
+ }
+ end
end
{
diff --git a/app/graphql/mutations/ci/pipeline_schedule/update.rb b/app/graphql/mutations/ci/pipeline_schedule/update.rb
index a0b5e793ecb..aff0a5494e7 100644
--- a/app/graphql/mutations/ci/pipeline_schedule/update.rb
+++ b/app/graphql/mutations/ci/pipeline_schedule/update.rb
@@ -43,7 +43,7 @@ module Mutations
def resolve(id:, variables: [], **pipeline_schedule_attrs)
schedule = authorized_find!(id: id)
- params = pipeline_schedule_attrs.merge(variables_attributes: variables.map(&:to_h))
+ params = pipeline_schedule_attrs.merge(variables_attributes: variable_attributes_for(variables))
service_response = ::Ci::PipelineSchedules::UpdateService
.new(schedule, current_user, params)
@@ -54,6 +54,18 @@ module Mutations
errors: service_response.errors
}
end
+
+ private
+
+ def variable_attributes_for(variables)
+ variables.map do |variable|
+ variable.to_h.tap do |hash|
+ hash[:id] = GlobalID::Locator.locate(hash[:id]).id if hash[:id]
+
+ hash[:_destroy] = hash.delete(:destroy)
+ end
+ end
+ end
end
end
end
diff --git a/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb b/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb
index 54a6ad92448..eb6a78eb67a 100644
--- a/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb
+++ b/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb
@@ -8,11 +8,18 @@ module Mutations
description 'Attributes for the pipeline schedule variable.'
+ PipelineScheduleVariableID = ::Types::GlobalIDType[::Ci::PipelineScheduleVariable]
+
+ argument :id, PipelineScheduleVariableID, required: false, description: 'ID of the variable to mutate.'
+
argument :key, GraphQL::Types::String, required: true, description: 'Name of the variable.'
argument :value, GraphQL::Types::String, required: true, description: 'Value of the variable.'
argument :variable_type, Types::Ci::VariableTypeEnum, required: true, description: 'Type of the variable.'
+
+ argument :destroy, GraphQL::Types::Boolean, required: false,
+ description: 'Boolean option to destroy the variable.'
end
end
end
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 d4e55fd1792..082c345adf6 100644
--- a/app/graphql/mutations/ci/project_ci_cd_settings_update.rb
+++ b/app/graphql/mutations/ci/project_ci_cd_settings_update.rb
@@ -39,7 +39,7 @@ module Mutations
def resolve(full_path:, **args)
project = authorized_find!(full_path)
- if args[:job_token_scope_enabled] && project.frozen_outbound_job_token_scopes?
+ if args[:job_token_scope_enabled]
raise Gitlab::Graphql::Errors::ArgumentError, 'job_token_scope_enabled can only be set to false'
end
diff --git a/app/graphql/mutations/ci/runner/create.rb b/app/graphql/mutations/ci/runner/create.rb
index 7eca6c27d10..4d4134781a5 100644
--- a/app/graphql/mutations/ci/runner/create.rb
+++ b/app/graphql/mutations/ci/runner/create.rb
@@ -37,8 +37,6 @@ module Mutations
parse_gid(**args)
- check_feature_flag(**args)
-
super
end
@@ -79,28 +77,6 @@ module Mutations
GitlabSchema.parse_gid(args[:project_id], expected_type: ::Project)
end
end
-
- def check_feature_flag(**args)
- case args[:runner_type]
- when 'instance_type'
- if Feature.disabled?(:create_runner_workflow_for_admin, current_user)
- raise Gitlab::Graphql::Errors::ResourceNotAvailable,
- '`create_runner_workflow_for_admin` feature flag is disabled.'
- end
- when 'group_type'
- namespace = find_object(**args).sync
- if Feature.disabled?(:create_runner_workflow_for_namespace, namespace)
- raise Gitlab::Graphql::Errors::ResourceNotAvailable,
- '`create_runner_workflow_for_namespace` feature flag is disabled.'
- end
- when 'project_type'
- project = find_object(**args).sync
- if project && Feature.disabled?(:create_runner_workflow_for_namespace, project.namespace)
- raise Gitlab::Graphql::Errors::ResourceNotAvailable,
- '`create_runner_workflow_for_namespace` feature flag is disabled.'
- end
- end
- end
end
end
end
diff --git a/app/graphql/mutations/environments/create.rb b/app/graphql/mutations/environments/create.rb
index 271585eb06c..f18ce0eba97 100644
--- a/app/graphql/mutations/environments/create.rb
+++ b/app/graphql/mutations/environments/create.rb
@@ -35,6 +35,11 @@ module Mutations
required: false,
description: 'Cluster agent of the environment.'
+ argument :kubernetes_namespace,
+ GraphQL::Types::String,
+ required: false,
+ description: 'Kubernetes namespace of the environment.'
+
field :environment,
Types::EnvironmentType,
null: true,
diff --git a/app/graphql/mutations/environments/update.rb b/app/graphql/mutations/environments/update.rb
index 431a7add00e..07ab22685cc 100644
--- a/app/graphql/mutations/environments/update.rb
+++ b/app/graphql/mutations/environments/update.rb
@@ -28,6 +28,11 @@ module Mutations
required: false,
description: 'Cluster agent of the environment.'
+ argument :kubernetes_namespace,
+ GraphQL::Types::String,
+ required: false,
+ description: 'Kubernetes namespace of the environment.'
+
field :environment,
Types::EnvironmentType,
null: true,
diff --git a/app/graphql/resolvers/alert_management/http_integrations_resolver.rb b/app/graphql/resolvers/alert_management/http_integrations_resolver.rb
index 225e20bab83..ac04e0967e6 100644
--- a/app/graphql/resolvers/alert_management/http_integrations_resolver.rb
+++ b/app/graphql/resolvers/alert_management/http_integrations_resolver.rb
@@ -35,7 +35,7 @@ module Resolvers
end
def http_integrations
- ::AlertManagement::HttpIntegrationsFinder.new(project, {}).execute
+ ::AlertManagement::HttpIntegrationsFinder.new(project, { type_identifier: :http }).execute
end
end
end
diff --git a/app/graphql/resolvers/alert_management/integrations_resolver.rb b/app/graphql/resolvers/alert_management/integrations_resolver.rb
index a97650e95d9..9b20d3367f1 100644
--- a/app/graphql/resolvers/alert_management/integrations_resolver.rb
+++ b/app/graphql/resolvers/alert_management/integrations_resolver.rb
@@ -40,7 +40,7 @@ module Resolvers
def http_integrations
return [] unless http_integrations_allowed?
- ::AlertManagement::HttpIntegrationsFinder.new(project, {}).execute
+ ::AlertManagement::HttpIntegrationsFinder.new(project, { type_identifier: :http }).execute
end
def prometheus_integrations_allowed?
diff --git a/app/graphql/resolvers/ci/inherited_variables_resolver.rb b/app/graphql/resolvers/ci/inherited_variables_resolver.rb
index 01f966942a4..4e83265e247 100644
--- a/app/graphql/resolvers/ci/inherited_variables_resolver.rb
+++ b/app/graphql/resolvers/ci/inherited_variables_resolver.rb
@@ -5,8 +5,12 @@ module Resolvers
class InheritedVariablesResolver < BaseResolver
type Types::Ci::ProjectVariableType.connection_type, null: true
- def resolve
- object.group&.self_and_ancestors&.flat_map(&:variables) || []
+ argument :sort, Types::Ci::GroupVariablesSortEnum,
+ required: false, default_value: :created_desc,
+ description: 'Sort variables by the criteria.'
+
+ def resolve(sort:)
+ ::Ci::GroupVariablesFinder.new(object, sort).execute
end
end
end
diff --git a/app/graphql/resolvers/ci/runner_job_count_resolver.rb b/app/graphql/resolvers/ci/runner_job_count_resolver.rb
new file mode 100644
index 00000000000..a43d3f3a100
--- /dev/null
+++ b/app/graphql/resolvers/ci/runner_job_count_resolver.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Ci
+ class RunnerJobCountResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type GraphQL::Types::Int, null: true
+
+ authorize :read_runner
+ authorizes_object!
+
+ argument :statuses, [::Types::Ci::JobStatusEnum],
+ required: false,
+ description: 'Filter jobs by status.',
+ alpha: { milestone: '16.2' }
+
+ alias_method :runner, :object
+
+ def resolve(statuses: nil)
+ BatchLoader::GraphQL.for(runner.id).batch(key: [:job_count, statuses]) do |runner_ids, loader, _args|
+ counts_by_runner = calculate_job_count_per_runner(runner_ids, statuses)
+
+ runner_ids.each do |runner_id|
+ loader.call(runner_id, counts_by_runner[runner_id]&.count || 0)
+ end
+ end
+ end
+
+ private
+
+ def calculate_job_count_per_runner(runner_ids, statuses)
+ # rubocop: disable CodeReuse/ActiveRecord
+ builds_tbl = ::Ci::Build.arel_table
+ runners_tbl = ::Ci::Runner.arel_table
+ lateral_query = ::Ci::Build.select(1).where(builds_tbl['runner_id'].eq(runners_tbl['id']))
+ lateral_query = lateral_query.where(status: statuses) if statuses
+ # We limit to 1 above the JOB_COUNT_LIMIT to indicate that more items exist after JOB_COUNT_LIMIT
+ lateral_query = lateral_query.limit(::Types::Ci::RunnerType::JOB_COUNT_LIMIT + 1)
+ ::Ci::Runner.joins("JOIN LATERAL (#{lateral_query.to_sql}) builds_with_limit ON true")
+ .id_in(runner_ids)
+ .select(:id, Arel.star.count.as('count'))
+ .group(:id)
+ .index_by(&:id)
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb
index 735e38c1a5c..632655d3681 100644
--- a/app/graphql/resolvers/ci/runners_resolver.rb
+++ b/app/graphql/resolvers/ci/runners_resolver.rb
@@ -4,6 +4,7 @@ module Resolvers
module Ci
class RunnersResolver < BaseResolver
include LooksAhead
+ include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::Ci::RunnerType.connection_type, null: true
@@ -105,3 +106,5 @@ module Resolvers
end
end
end
+
+Resolvers::Ci::RunnersResolver.prepend_mod
diff --git a/app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb b/app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb
index 2ea7a02bf15..d9bcf39b818 100644
--- a/app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb
+++ b/app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb
@@ -20,17 +20,15 @@ module Issues
end
def preloads
- preload_hash = {
+ {
alert_management_alert: [:alert_management_alert],
assignees: [:assignees],
participants: Issue.participant_includes,
timelogs: [:timelogs],
customer_relations_contacts: { customer_relations_contacts: [:group] },
- escalation_status: [:incident_management_issuable_escalation_status]
+ escalation_status: [:incident_management_issuable_escalation_status],
+ type: :work_item_type
}
- 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 b9326015ac0..c0a068097a7 100644
--- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb
+++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
@@ -11,6 +11,11 @@ module ResolvesMergeRequests
end
def resolve_with_lookahead(**args)
+ if args[:group_id]
+ args[:group_id] = ::GitlabSchema.parse_gid(args[:group_id], expected_type: ::Group).model_id
+ args[:include_subgroups] = true
+ end
+
mr_finder = MergeRequestsFinder.new(current_user, args.compact)
finder = Gitlab::Graphql::Loaders::IssuableLoader.new(mr_parent, mr_finder)
diff --git a/app/graphql/resolvers/issues/base_resolver.rb b/app/graphql/resolvers/issues/base_resolver.rb
index fefd17d5e20..495b72231fc 100644
--- a/app/graphql/resolvers/issues/base_resolver.rb
+++ b/app/graphql/resolvers/issues/base_resolver.rb
@@ -16,6 +16,9 @@ module Resolvers
argument :assignee_usernames, [GraphQL::Types::String],
required: false,
description: 'Usernames of users assigned to the issue.'
+ argument :assignee_wildcard_id, ::Types::AssigneeWildcardIdEnum,
+ required: false,
+ description: 'Filter by assignee wildcard. Incompatible with assigneeUsername and assigneeUsernames.'
argument :author_username, GraphQL::Types::String,
required: false,
description: 'Username of the author of the issue.'
@@ -148,6 +151,7 @@ module Resolvers
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)
+ rewrite_param_name(args, :assignee_wildcard_id, :assignee_id)
end
def rewrite_param_name(params, old_name, new_name)
@@ -163,7 +167,7 @@ module Resolvers
end
def mutually_exclusive_assignee_username_args
- [:assignee_usernames, :assignee_username]
+ [:assignee_usernames, :assignee_username, :assignee_wildcard_id]
end
def params_not_mutually_exclusive(args, mutually_exclusive_args)
@@ -171,7 +175,7 @@ module Resolvers
arg_str = mutually_exclusive_args.map { |x| x.to_s.camelize(:lower) }.join(', ')
raise ::Gitlab::Graphql::Errors::ArgumentError,
- "only one of [#{arg_str}] arguments is allowed at the same time."
+ "only one of [#{arg_str}] arguments is allowed at the same time."
end
end
# rubocop:enable Graphql/ResolverType
diff --git a/app/graphql/resolvers/metrics/dashboard_resolver.rb b/app/graphql/resolvers/metrics/dashboard_resolver.rb
deleted file mode 100644
index 5abad0de539..00000000000
--- a/app/graphql/resolvers/metrics/dashboard_resolver.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-module Resolvers
- module Metrics
- class DashboardResolver < Resolvers::BaseResolver
- type Types::Metrics::DashboardType, null: true
- calls_gitaly!
-
- argument :path, GraphQL::Types::String,
- required: true,
- description: <<~MD
- Path to a file which defines a metrics dashboard eg: `"config/prometheus/common_metrics.yml"`.
- MD
-
- alias_method :environment, :object
-
- def resolve(path:)
- return if Feature.enabled?(:remove_monitor_metrics)
- return unless environment
-
- ::PerformanceMonitoring::PrometheusDashboard.find_for(path: path, **service_params)
- end
-
- private
-
- def service_params
- {
- project: environment.project,
- user: current_user,
- options: { environment: environment }
- }
- end
- end
- end
-end
diff --git a/app/graphql/resolvers/user_merge_requests_resolver_base.rb b/app/graphql/resolvers/user_merge_requests_resolver_base.rb
index b2d85307c49..72dbc0a93e9 100644
--- a/app/graphql/resolvers/user_merge_requests_resolver_base.rb
+++ b/app/graphql/resolvers/user_merge_requests_resolver_base.rb
@@ -4,6 +4,14 @@ module Resolvers
class UserMergeRequestsResolverBase < MergeRequestsResolver
include ResolvesProject
+ argument :group_id,
+ type: ::Types::GlobalIDType[::Group],
+ required: false,
+ description: <<~DESC
+ The global ID of the group the authored merge requests should be in.
+ Merge requests in subgroups are included.
+ DESC
+
argument :project_path,
type: GraphQL::Types::String,
required: false,
diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb
index 36dd930c3d9..c17406b3e56 100644
--- a/app/graphql/types/alert_management/alert_type.rb
+++ b/app/graphql/types/alert_management/alert_type.rb
@@ -111,13 +111,6 @@ module Types
null: true,
description: 'Assignees of the alert.'
- field :metrics_dashboard_url,
- GraphQL::Types::String,
- null: true,
- description: 'URL for metrics embed for the alert.',
- deprecated: { reason: 'Returns no data. Underlying feature was removed in 16.0',
- milestone: '16.0' }
-
field :runbook,
GraphQL::Types::String,
null: true,
@@ -143,12 +136,6 @@ module Types
method: :details_url,
null: false,
description: 'URL of the alert.'
-
- def metrics_dashboard_url
- return if Feature.enabled?(:remove_monitor_metrics)
-
- object.metrics_dashboard_url
- end
end
end
end
diff --git a/app/graphql/types/assignee_wildcard_id_enum.rb b/app/graphql/types/assignee_wildcard_id_enum.rb
new file mode 100644
index 00000000000..09afab7de37
--- /dev/null
+++ b/app/graphql/types/assignee_wildcard_id_enum.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ class AssigneeWildcardIdEnum < BaseEnum
+ graphql_name 'AssigneeWildcardId'
+ description 'Assignee ID wildcard values'
+
+ value 'NONE', 'No assignee is assigned.'
+ value 'ANY', 'An assignee is assigned.'
+ end
+end
diff --git a/app/graphql/types/boards/assignee_wildcard_id_enum.rb b/app/graphql/types/boards/assignee_wildcard_id_enum.rb
deleted file mode 100644
index ba9058a78d9..00000000000
--- a/app/graphql/types/boards/assignee_wildcard_id_enum.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-module Types
- module Boards
- class AssigneeWildcardIdEnum < BaseEnum
- graphql_name 'AssigneeWildcardId'
- description 'Assignee ID wildcard values'
-
- value 'NONE', 'No assignee is assigned.'
- value 'ANY', 'An assignee is assigned.'
- end
- end
-end
diff --git a/app/graphql/types/boards/board_issue_input_type.rb b/app/graphql/types/boards/board_issue_input_type.rb
index 897e3d05948..ea7c207cda2 100644
--- a/app/graphql/types/boards/board_issue_input_type.rb
+++ b/app/graphql/types/boards/board_issue_input_type.rb
@@ -17,9 +17,9 @@ module Types
required: false,
description: 'Search query for issue title or description.'
- argument :assignee_wildcard_id, ::Types::Boards::AssigneeWildcardIdEnum,
+ argument :assignee_wildcard_id, ::Types::AssigneeWildcardIdEnum,
required: false,
- description: 'Filter by assignee wildcard. Incompatible with assigneeUsername.'
+ description: 'Filter by assignee wildcard. Incompatible with assigneeUsername and assigneeUsernames.'
argument :confidential, GraphQL::Types::Boolean,
required: false,
diff --git a/app/graphql/types/ci/config/include_type.rb b/app/graphql/types/ci/config/include_type.rb
index 71eb8f755ab..b5816453a51 100644
--- a/app/graphql/types/ci/config/include_type.rb
+++ b/app/graphql/types/ci/config/include_type.rb
@@ -15,22 +15,22 @@ module Types
field :location,
GraphQL::Types::String,
null: true,
- description: 'File location. It can be masked if it contains masked variables, e.g., ' \
- '".gitlab/ci/build-images.gitlab-ci.yml".'
+ description: 'File location. It can be masked if it contains masked variables. For example, ' \
+ '`".gitlab/ci/build-images.gitlab-ci.yml"`.'
field :blob,
GraphQL::Types::String,
null: true,
- description: 'File blob location. It can be masked if it contains masked variables, e.g., ' \
- '"https://gitlab.com/gitlab-org/gitlab/-/blob/e52d6d0246d7375291850e61f0abc101fbda9dc2' \
- '/.gitlab/ci/build-images.gitlab-ci.yml".'
+ description: 'File blob location. It can be masked if it contains masked variables. For example, ' \
+ '`"https://gitlab.com/gitlab-org/gitlab/-/blob/e52d6d0246d7375291850e61f0abc101fbda9dc2' \
+ '/.gitlab/ci/build-images.gitlab-ci.yml"`.'
field :raw,
GraphQL::Types::String,
null: true,
- description: 'File raw location. It can be masked if it contains masked variables, e.g., ' \
- '"https://gitlab.com/gitlab-org/gitlab/-/raw/e52d6d0246d7375291850e61f0abc101fbda9dc2' \
- '/.gitlab/ci/build-images.gitlab-ci.yml".'
+ description: 'File raw location. It can be masked if it contains masked variables. For example, ' \
+ '`"https://gitlab.com/gitlab-org/gitlab/-/raw/e52d6d0246d7375291850e61f0abc101fbda9dc2' \
+ '/.gitlab/ci/build-images.gitlab-ci.yml"`.'
field :extra, # rubocop:disable Graphql/JSONType
GraphQL::Types::JSON,
diff --git a/app/graphql/types/ci/group_variable_type.rb b/app/graphql/types/ci/group_variable_type.rb
index f9ed54f0d10..7e2afba0d53 100644
--- a/app/graphql/types/ci/group_variable_type.rb
+++ b/app/graphql/types/ci/group_variable_type.rb
@@ -21,6 +21,10 @@ module Types
field :protected, GraphQL::Types::Boolean,
null: true,
description: 'Indicates whether the variable is protected.'
+
+ field :description, GraphQL::Types::String,
+ null: true,
+ description: 'Description of the variable.'
end
end
end
diff --git a/app/graphql/types/ci/group_variables_sort_enum.rb b/app/graphql/types/ci/group_variables_sort_enum.rb
new file mode 100644
index 00000000000..5cf9fd4039b
--- /dev/null
+++ b/app/graphql/types/ci/group_variables_sort_enum.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # Not inheriting from Types::SortEnum since we only want
+ # to implement a subset of the sort values it defines.
+ class GroupVariablesSortEnum < BaseEnum
+ graphql_name 'CiGroupVariablesSort'
+ description 'Values for sorting inherited variables'
+
+ # Borrowed from Types::SortEnum
+ # These values/descriptions should stay in-sync as much as possible.
+ value 'CREATED_DESC', 'Created at descending order.', value: :created_desc
+ value 'CREATED_ASC', 'Created at ascending order.', value: :created_asc
+
+ value 'KEY_DESC', 'Key by descending order.', value: :key_desc
+ value 'KEY_ASC', 'Key by ascending order.', value: :key_asc
+ end
+ end
+end
diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb
index a779ceb2e2a..02b10f3e4bd 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -87,8 +87,10 @@ module Types
description: 'Play path of the job.'
field :playable, GraphQL::Types::Boolean, null: false, method: :playable?,
description: 'Indicates the job can be played.'
- field :previous_stage_jobs_or_needs, Types::Ci::JobNeedUnion.connection_type, null: true,
- description: 'Jobs that must complete before the job runs. Returns `BuildNeed`, which is the needed jobs if the job uses the `needs` keyword, or the previous stage jobs otherwise.'
+ field :previous_stage_jobs_or_needs, Types::Ci::JobNeedUnion.connection_type,
+ null: true,
+ description: 'Jobs that must complete before the job runs. Returns `BuildNeed`, ' \
+ 'which is the needed jobs if the job uses the `needs` keyword, or the previous stage jobs otherwise.'
field :ref_name, GraphQL::Types::String, null: true,
description: 'Ref name of the job.'
field :ref_path, GraphQL::Types::String, null: true,
@@ -179,7 +181,9 @@ module Types
stages = pipeline.stages.by_position(positions)
stages.each do |stage|
- loader.call([pipeline, stage.position], stage.latest_statuses)
+ # Without `.to_a`, the memoization will only preserve the activerecord relation object. And when there is
+ # a call, the SQL query will be executed again.
+ loader.call([pipeline, stage.position], stage.latest_statuses.to_a)
end
end
end
diff --git a/app/graphql/types/ci/project_variable_type.rb b/app/graphql/types/ci/project_variable_type.rb
index 2a5375045e5..a9679000511 100644
--- a/app/graphql/types/ci/project_variable_type.rb
+++ b/app/graphql/types/ci/project_variable_type.rb
@@ -21,6 +21,10 @@ module Types
field :masked, GraphQL::Types::Boolean,
null: true,
description: 'Indicates whether the variable is masked.'
+
+ field :description, GraphQL::Types::String,
+ null: true,
+ description: 'Description of the variable.'
end
end
end
diff --git a/app/graphql/types/ci/runner_sort_enum.rb b/app/graphql/types/ci/runner_sort_enum.rb
index 8f2a13bd699..4195eb043ed 100644
--- a/app/graphql/types/ci/runner_sort_enum.rb
+++ b/app/graphql/types/ci/runner_sort_enum.rb
@@ -15,3 +15,5 @@ module Types
end
end
end
+
+Types::Ci::RunnerSortEnum.prepend_mod
diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb
index 8e509cc8493..2baf64ca663 100644
--- a/app/graphql/types/ci/runner_type.rb
+++ b/app/graphql/types/ci/runner_type.rb
@@ -24,8 +24,9 @@ module Types
field :admin_url, GraphQL::Types::String, null: true,
description: 'Admin URL of the runner. Only available for administrators.'
field :architecture_name, GraphQL::Types::String, null: true,
- description: 'Architecture provided by the the runner.',
- method: :architecture
+ deprecated: { reason: "Use field in `manager` object instead", milestone: '16.2' },
+ description: 'Architecture provided by the the runner.',
+ method: :architecture
field :contacted_at, Types::TimeType, null: true,
description: 'Timestamp of last contact from this runner.',
method: :contacted_at
@@ -46,17 +47,20 @@ module Types
description: 'URL of the registration page of the runner manager. Only available for the creator of the runner for a limited time during registration.',
alpha: { milestone: '15.11' }
field :executor_name, GraphQL::Types::String, null: true,
- description: 'Executor last advertised by the runner.',
- method: :executor_name
+ deprecated: { reason: "Use field in `manager` object instead", milestone: '16.2' },
+ description: 'Executor last advertised by the runner.',
+ method: :executor_name
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,
- description: 'IP address of the runner.'
+ deprecated: { reason: "Use field in `manager` object instead", milestone: '16.2' },
+ description: 'IP address of the runner.'
field :job_count, GraphQL::Types::Int, null: true,
- description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist)."
+ description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist).",
+ resolver: ::Resolvers::Ci::RunnerJobCountResolver
field :job_execution_status,
Types::Ci::RunnerJobExecutionStatusEnum,
null: true,
@@ -82,8 +86,9 @@ module Types
field :paused, GraphQL::Types::Boolean, null: false,
description: 'Indicates the runner is paused and not available to run jobs.'
field :platform_name, GraphQL::Types::String, null: true,
- description: 'Platform provided by the runner.',
- method: :platform
+ deprecated: { reason: "Use field in `manager` object instead", milestone: '16.2' },
+ description: 'Platform provided by the runner.',
+ method: :platform
field :project_count, GraphQL::Types::Int, null: true,
description: 'Number of projects that the runner is associated with.'
field :projects,
@@ -94,7 +99,8 @@ module Types
field :register_admin_url, GraphQL::Types::String, null: true,
description: 'URL of the temporary registration page of the runner. Only available before the runner is registered. Only available for administrators.'
field :revision, GraphQL::Types::String, null: true,
- description: 'Revision of the runner.'
+ deprecated: { reason: "Use field in `manager` object instead", milestone: '16.2' },
+ description: 'Revision of the runner.'
field :run_untagged, GraphQL::Types::Boolean, null: false,
description: 'Indicates the runner is able to run untagged jobs.'
field :runner_type, ::Types::Ci::RunnerTypeEnum, null: false,
@@ -112,7 +118,8 @@ module Types
description: 'Runner token expiration time.',
method: :token_expires_at
field :version, GraphQL::Types::String, null: true,
- description: 'Version of the runner.'
+ deprecated: { reason: "Use field in `manager` object instead", milestone: '16.2' },
+ description: 'Version of the runner.'
markdown_field :maintenance_note_html, null: true
@@ -120,28 +127,6 @@ module Types
::MarkupHelper.markdown(object.maintenance_note, context.to_h.dup)
end
- def job_count
- BatchLoader::GraphQL.for(runner.id).batch(key: :job_count) do |runner_ids, loader, _args|
- # rubocop: disable CodeReuse/ActiveRecord
- # We limit to 1 above the JOB_COUNT_LIMIT to indicate that more items exist after JOB_COUNT_LIMIT
- builds_tbl = ::Ci::Build.arel_table
- runners_tbl = ::Ci::Runner.arel_table
- lateral_query = ::Ci::Build.select(1)
- .where(builds_tbl['runner_id'].eq(runners_tbl['id']))
- .limit(JOB_COUNT_LIMIT + 1)
- counts = ::Ci::Runner.joins("JOIN LATERAL (#{lateral_query.to_sql}) builds_with_limit ON true")
- .id_in(runner_ids)
- .select(:id, Arel.star.count.as('count'))
- .group(:id)
- .index_by(&:id)
- # rubocop: enable CodeReuse/ActiveRecord
-
- runner_ids.each do |runner_id|
- loader.call(runner_id, counts[runner_id]&.count || 0)
- end
- end
- end
-
def admin_url
Gitlab::Routing.url_helpers.admin_runner_url(runner) if can_admin_runners?
end
diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb
index c0f3d1db57b..a9d8075329d 100644
--- a/app/graphql/types/ci/stage_type.rb
+++ b/app/graphql/types/ci/stage_type.rb
@@ -33,7 +33,7 @@ module Types
by_pipeline = keys.group_by(&:pipeline)
include_needs = keys.any? do |k|
k.requires?(%i[nodes jobs nodes needs]) ||
- k.requires?(%i[nodes jobs nodes previousStageJobsAndNeeds])
+ k.requires?(%i[nodes jobs nodes previousStageJobsOrNeeds])
end
by_pipeline.each do |pl, key_group|
diff --git a/app/graphql/types/current_user_todos.rb b/app/graphql/types/current_user_todos.rb
index 4c4cb516979..d5441ea1d15 100644
--- a/app/graphql/types/current_user_todos.rb
+++ b/app/graphql/types/current_user_todos.rb
@@ -17,7 +17,8 @@ module Types
def current_user_todos(state: nil)
state ||= %i[done pending] # TodosFinder treats a `nil` state param as `pending`
- key = [state, unpresented.class.name]
+ target_type_name = unpresented.try(:todoable_target_type_name) || unpresented.class.name
+ key = [state, target_type_name]
BatchLoader::GraphQL.for(unpresented).batch(default_value: [], key: key) do |targets, loader, args|
state, klass_name = args[:key]
diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb
index 936ad52200c..aee09e5a143 100644
--- a/app/graphql/types/environment_type.rb
+++ b/app/graphql/types/environment_type.rb
@@ -33,6 +33,9 @@ module Types
field :external_url, GraphQL::Types::String, null: true,
description: 'External URL of the environment.'
+ field :kubernetes_namespace, GraphQL::Types::String, null: true,
+ description: 'Kubernetes namespace of the environment.'
+
field :created_at, Types::TimeType,
description: 'When the environment was created.'
@@ -51,11 +54,6 @@ module Types
field :environment_type, GraphQL::Types::String,
description: 'Folder name of the environment.'
- field :metrics_dashboard, Types::Metrics::DashboardType, null: true,
- description: 'Metrics dashboard schema for the environment.',
- resolver: Resolvers::Metrics::DashboardResolver,
- deprecated: { reason: 'Returns no data. Underlying feature was removed in 16.0', milestone: '16.0' }
-
field :latest_opened_most_severe_alert,
Types::AlertManagement::AlertType,
null: true,
diff --git a/app/graphql/types/ide_type.rb b/app/graphql/types/ide_type.rb
new file mode 100644
index 00000000000..34447577f23
--- /dev/null
+++ b/app/graphql/types/ide_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+ class IdeType < BaseObject
+ graphql_name 'Ide'
+ description 'IDE settings and feature flags.'
+
+ authorize :read_user
+
+ field :code_suggestions_enabled, GraphQL::Types::Boolean, null: false,
+ description: 'Indicates whether AI assisted code suggestions are enabled.'
+
+ def code_suggestions_enabled
+ object.can?(:access_code_suggestions)
+ end
+ end
+end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index f32dfc0dbcf..99c719f1402 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -35,7 +35,7 @@ module Types
field :iid, GraphQL::Types::String, null: false,
description: 'Internal ID of the merge request.'
field :merge_when_pipeline_succeeds, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS).'
+ description: 'Indicates if the merge has been set to auto-merge.'
field :merged_at, Types::TimeType, null: true, complexity: 5,
description: 'Timestamp of when the merge request was merged, null if not merged.'
field :project, Types::ProjectType, null: false,
@@ -207,7 +207,7 @@ module Types
field :has_ci, GraphQL::Types::Boolean, null: false, method: :has_ci?,
description: 'Indicates if the merge request has CI.'
field :merge_user, Types::UserType, null: true,
- description: 'User who merged this merge request or set it to merge when pipeline succeeds.'
+ description: 'User who merged this merge request or set it to auto-merge.'
field :mergeable, GraphQL::Types::Boolean, null: false, method: :mergeable?, calls_gitaly: true,
description: 'Indicates if the merge request is mergeable.'
field :security_auto_fix, GraphQL::Types::Boolean, null: true,
diff --git a/app/graphql/types/metrics/dashboard_type.rb b/app/graphql/types/metrics/dashboard_type.rb
deleted file mode 100644
index 5570b904d79..00000000000
--- a/app/graphql/types/metrics/dashboard_type.rb
+++ /dev/null
@@ -1,33 +0,0 @@
-# frozen_string_literal: true
-
-module Types
- module Metrics
- # rubocop: disable Graphql/AuthorizeTypes
- # Authorization is performed at environment level
- class DashboardType < ::Types::BaseObject
- graphql_name 'MetricsDashboard'
-
- field :path, GraphQL::Types::String, null: true,
- description: 'Path to a file with the dashboard definition.'
-
- field :schema_validation_warnings,
- [GraphQL::Types::String],
- null: true,
- description: 'Dashboard schema validation warnings.'
-
- field :annotations,
- Types::Metrics::Dashboards::AnnotationType.connection_type,
- null: true,
- description: 'Annotations added to the dashboard.',
- resolver: Resolvers::Metrics::Dashboards::AnnotationResolver
-
- # In order to maintain backward compatibility we need to return NULL when there are no warnings
- # and dashboard validation returns an empty array when there are no issues.
- def schema_validation_warnings
- warnings = object.schema_validation_warnings
- warnings unless warnings.empty?
- end
- end
- # rubocop: enable Graphql/AuthorizeTypes
- end
-end
diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb
index a1d721856a9..ef4edcddbe9 100644
--- a/app/graphql/types/project_statistics_type.rb
+++ b/app/graphql/types/project_statistics_type.rb
@@ -35,3 +35,5 @@ module Types
description: 'Wiki size of the project in bytes.'
end
end
+
+Types::ProjectStatisticsType.prepend_mod_with('Types::ProjectStatisticsType')
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index f8a516501c3..992663b4d98 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -727,6 +727,8 @@ module Types
if minimum_access_level.nil?
object.forks.public_or_visible_to_user(current_user)
else
+ return [] if current_user.nil?
+
object.forks.visible_to_user_and_access_level(current_user, minimum_access_level)
end
end
diff --git a/app/graphql/types/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb
index 67ee0589882..dbed51ac71a 100644
--- a/app/graphql/types/root_storage_statistics_type.rb
+++ b/app/graphql/types/root_storage_statistics_type.rb
@@ -8,11 +8,19 @@ module Types
field :build_artifacts_size, GraphQL::Types::Float, null: false, description: 'CI artifacts size in bytes.'
field :container_registry_size, GraphQL::Types::Float, null: false, description: 'Container Registry size in bytes.'
+ field :container_registry_size_is_estimated, GraphQL::Types::Boolean, method: :registry_size_estimated, null: false,
+ description: 'Indicates whether the deduplicated Container Registry size for ' \
+ 'the namespace is an estimated value or not.'
field :dependency_proxy_size, GraphQL::Types::Float, null: false, description: 'Dependency Proxy sizes in bytes.'
field :lfs_objects_size, GraphQL::Types::Float, null: false, description: 'LFS objects size in bytes.'
field :packages_size, GraphQL::Types::Float, null: false, description: 'Packages size in bytes.'
- field :pipeline_artifacts_size, GraphQL::Types::Float, null: false, description: 'CI pipeline artifacts size in bytes.'
- field :registry_size_estimated, GraphQL::Types::Boolean, null: false, description: 'Indicates whether the deduplicated Container Registry size for the namespace is an estimated value or not.'
+ field :pipeline_artifacts_size, GraphQL::Types::Float, null: false,
+ description: 'CI pipeline artifacts size in bytes.'
+ field :registry_size_estimated, GraphQL::Types::Boolean,
+ null: false,
+ deprecated: { reason: 'Use `container_registry_size_is_estimated`', milestone: '16.2' },
+ description: 'Indicates whether the deduplicated Container Registry size for ' \
+ 'the namespace is an estimated value or not.'
field :repository_size, GraphQL::Types::Float, null: false, description: 'Git repository size in bytes.'
field :snippets_size, GraphQL::Types::Float, null: false, description: 'Snippets size in bytes.'
field :storage_size, GraphQL::Types::Float, null: false, description: 'Total storage in bytes.'
@@ -20,3 +28,5 @@ module Types
field :wiki_size, GraphQL::Types::Float, null: false, description: 'Wiki size in bytes.'
end
end
+
+Types::RootStorageStatisticsType.prepend_mod_with('Types::RootStorageStatisticsType')
diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb
index 5357f2f8e66..9e5f6810aca 100644
--- a/app/graphql/types/user_interface.rb
+++ b/app/graphql/types/user_interface.rb
@@ -197,6 +197,17 @@ module Types
null: true,
description: 'Timestamp of when the user was created.'
+ field :pronouns,
+ type: ::GraphQL::Types::String,
+ null: true,
+ description: 'Pronouns of the user.'
+
+ field :ide,
+ type: Types::IdeType,
+ null: true,
+ description: 'IDE settings.',
+ method: :itself
+
definition_methods do
def resolve_type(object, context)
# in the absence of other information, we cannot tell - just default to
diff --git a/app/helpers/admin/application_settings/settings_helper.rb b/app/helpers/admin/application_settings/settings_helper.rb
index 0a7f20caa02..9ea07ba4e6e 100644
--- a/app/helpers/admin/application_settings/settings_helper.rb
+++ b/app/helpers/admin/application_settings/settings_helper.rb
@@ -16,12 +16,23 @@ module Admin
project.repository&.gitlab_ci_yml.blank?
end
+ def code_suggestions_description
+ link_start = code_suggestions_link_start(code_suggestions_docs_url)
+
+ # rubocop:disable Layout/LineLength
+ # rubocop:disable Style/FormatString
+ s_('CodeSuggestionsSM|Enable Code Suggestions for users of this instance. %{link_start}What are Code Suggestions?%{link_end}')
+ .html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ # rubocop:enable Style/FormatString
+ # rubocop:enable Layout/LineLength
+ end
+
def code_suggestions_token_explanation
link_start = code_suggestions_link_start(code_suggestions_pat_docs_url)
# rubocop:disable Layout/LineLength
# rubocop:disable Style/FormatString
- s_('CodeSuggestionsSM|Your personal access token from GitLab.com. See the %{link_start}documentation%{link_end} for information on creating a personal access token.')
+ s_('CodeSuggestionsSM|On GitLab.com, create a token. This token is required to use Code Suggestions on your self-managed instance. %{link_start}How do I create a token?%{link_end}')
.html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
# rubocop:enable Style/FormatString
# rubocop:enable Layout/LineLength
@@ -33,8 +44,8 @@ module Admin
# rubocop:disable Layout/LineLength
# rubocop:disable Style/FormatString
- s_('CodeSuggestionsSM|&#8226; Agree to the %{terms_link_start}GitLab Testing Agreement%{link_end}.%{br} &#8226; Acknowledge that GitLab will send data from the instance, including personal data, to Google for cloud hosting.%{br} &nbsp;&nbsp;&nbsp;We may also send data to %{ai_docs_link_start}third-party AI providers%{link_end} to provide this feature.')
- .html_safe % { terms_link_start: terms_link_start, ai_docs_link_start: ai_docs_link_start, link_end: '</a>'.html_safe, br: '</br>'.html_safe }
+ s_('CodeSuggestionsSM|By enabling this feature, you agree to the %{terms_link_start}GitLab Testing Agreement%{link_end} and acknowledge that GitLab will send data from the instance, including personal data, to our %{ai_docs_link_start}AI providers%{link_end} to provide this feature.')
+ .html_safe % { terms_link_start: terms_link_start, ai_docs_link_start: ai_docs_link_start, link_end: '</a>'.html_safe }
# rubocop:enable Style/FormatString
# rubocop:enable Layout/LineLength
end
@@ -53,7 +64,7 @@ module Admin
end
def code_suggestions_ai_docs_url
- 'https://docs.gitlab.com/ee/user/ai_features.html'
+ 'https://docs.gitlab.com/ee/user/ai_features.html#third-party-services'
end
def code_suggestions_pat_docs_url
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 7f1c28de8a7..ce338a8afdc 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -124,7 +124,8 @@ module ApplicationHelper
page: body_data_page,
page_type_id: controller.params[:id],
find_file: find_file_path,
- group: @group&.path
+ group: @group&.path,
+ group_full_path: @group&.full_path
}.merge(project_data)
end
@@ -135,6 +136,7 @@ module ApplicationHelper
project_id: @project.id,
project: @project.path,
group: @project.group&.path,
+ group_full_path: @project.group&.full_path,
namespace_id: @project.namespace&.id
}
end
@@ -274,15 +276,7 @@ module ApplicationHelper
end
def stylesheet_link_tag_defer(path)
- if startup_css_enabled?
- stylesheet_link_tag(path, media: "print", crossorigin: ActionController::Base.asset_host ? 'anonymous' : nil)
- else
- stylesheet_link_tag(path, media: "all", crossorigin: ActionController::Base.asset_host ? 'anonymous' : nil)
- end
- end
-
- def startup_css_enabled?
- !Feature.enabled?(:remove_startup_css) && !params.has_key?(:no_startup_css)
+ stylesheet_link_tag(path, media: "all", crossorigin: ActionController::Base.asset_host ? 'anonymous' : nil)
end
def sign_in_with_redirect?
@@ -336,7 +330,7 @@ module ApplicationHelper
class_names << 'with-system-header' if appearance.show_header?
class_names << 'with-system-footer' if appearance.show_footer?
- class_names
+ class_names.join(' ')
end
# Returns active css class when condition returns true
@@ -354,7 +348,7 @@ module ApplicationHelper
def linkedin_url(user)
name = user.linkedin
- if name =~ %r{\Ahttps?://(www\.)?linkedin\.com/in/}
+ if %r{\Ahttps?://(www\.)?linkedin\.com/in/}.match?(name)
name
else
"https://www.linkedin.com/in/#{name}"
@@ -363,7 +357,7 @@ module ApplicationHelper
def twitter_url(user)
name = user.twitter
- if name =~ %r{\Ahttps?://(www\.)?twitter\.com/}
+ if %r{\Ahttps?://(www\.)?twitter\.com/}.match?(name)
name
else
"https://twitter.com/#{name}"
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index adbf7ab7cf2..aa2466372e1 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -497,7 +497,8 @@ module ApplicationSettingsHelper
:projects_api_rate_limit_unauthenticated,
:gitlab_dedicated_instance,
:ci_max_includes,
- :allow_account_deletion
+ :allow_account_deletion,
+ :gitlab_shell_operation_limit
].tap do |settings|
next if Gitlab.com?
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index 0feaee2bd93..c928c6479de 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -53,7 +53,8 @@ module AuthHelper
saml: 'saml_login_button',
openid_connect: 'oidc_login_button',
github: 'github_login_button',
- gitlab: 'gitlab_oauth_login_button'
+ gitlab: 'gitlab_oauth_login_button',
+ facebook: 'facebook_login_button'
}[provider.to_sym]
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index be9306ce80b..6746e6549ec 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -118,8 +118,8 @@ module BlobHelper
"#{blob_raw_path.rpartition('/').first}/"
end
- # SVGs can contain malicious JavaScript; only include whitelisted
- # elements and attributes. Note that this whitelist is by no means complete
+ # SVGs can contain malicious JavaScript; only include allowlisted
+ # elements and attributes. Note that this allowlist is by no means complete
# and may omit some elements.
def sanitize_svg_data(data)
Gitlab::Sanitizers::SVG.clean(data)
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index 64d6ba155cd..6e0ba748d85 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -98,6 +98,68 @@ module ButtonHelper
href: href,
data: data
end
+
+ # Creates a link that looks like a button.
+ #
+ # It renders a Pajamas::ButtonComponent.
+ #
+ # It has the same API as `link_to`, but with some additional options
+ # specific to button rendering.
+ #
+ # Examples:
+ # # Default button
+ # link_button_to _('Foo'), some_path
+ #
+ # # Default button using a block
+ # link_button_to some_path do
+ # _('Foo')
+ # end
+ #
+ # # Confirm variant
+ # link_button_to _('Foo'), some_path, variant: :confirm
+ #
+ # # With icon
+ # link_button_to _('Foo'), some_path, icon: 'pencil'
+ #
+ # # Icon-only
+ # # NOTE: The content must be `nil` in order to correctly render. Use aria-label
+ # # to ensure the link is accessible.
+ # link_button_to nil, some_path, icon: 'pencil', 'aria-label': _('Foo')
+ #
+ # # Small button
+ # link_button_to _('Foo'), some_path, size: :small
+ #
+ # # Secondary category danger button
+ # link_button_to _('Foo'), some_path, variant: :danger, category: :secondary
+ #
+ # For accessibility, ensure that icon-only links have aria-label set.
+ def link_button_to(name = nil, href = nil, options = nil, &block)
+ if block
+ options = href
+ href = name
+ end
+
+ options ||= {}
+
+ # Ignore args that don't make sense for links, like disabled, loading, etc.
+ options_for_button = %i[
+ category
+ variant
+ size
+ block
+ selected
+ icon
+ target
+ method
+ ]
+
+ args = options.slice(*options_for_button)
+ button_options = options.except(*options_for_button)
+
+ render Pajamas::ButtonComponent.new(href: href, **args, button_options: button_options) do
+ block.present? ? yield : name
+ end
+ end
end
ButtonHelper.prepend_mod_with('ButtonHelper')
diff --git a/app/helpers/calendar_helper.rb b/app/helpers/calendar_helper.rb
index ad4116fc3da..d70a860d468 100644
--- a/app/helpers/calendar_helper.rb
+++ b/app/helpers/calendar_helper.rb
@@ -3,7 +3,7 @@
module CalendarHelper
def calendar_url_options
{ format: :ics,
- feed_token: current_user.try(:feed_token),
+ feed_token: generate_feed_token(:ics),
due_date: Issue::DueNextMonthAndPreviousTwoWeeks.name,
sort: 'closest_future_date' }
end
diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb
index a7e1de173bd..991b1f4d74e 100644
--- a/app/helpers/ci/jobs_helper.rb
+++ b/app/helpers/ci/jobs_helper.rb
@@ -2,16 +2,16 @@
module Ci
module JobsHelper
- def jobs_data
+ def jobs_data(project, build)
{
- "endpoint" => project_job_path(@project, @build, format: :json),
- "project_path" => @project.full_path,
+ "endpoint" => project_job_path(project, build, format: :json),
+ "project_path" => project.full_path,
"artifact_help_url" => help_page_path('user/gitlab_com/index.md', anchor: 'gitlab-cicd'),
"deployment_help_url" => help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'troubleshooting'),
- "runner_settings_url" => project_runners_path(@build.project, anchor: 'js-runners-settings'),
- "page_path" => project_job_path(@project, @build),
- "build_status" => @build.status,
- "build_stage" => @build.stage_name,
+ "runner_settings_url" => project_runners_path(build.project, anchor: 'js-runners-settings'),
+ "page_path" => project_job_path(project, build),
+ "build_status" => build.status,
+ "build_stage" => build.stage_name,
"log_state" => '',
"build_options" => javascript_build_options,
"retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs')
diff --git a/app/helpers/ci/pipeline_schedules_helper.rb b/app/helpers/ci/pipeline_schedules_helper.rb
new file mode 100644
index 00000000000..e5125353b99
--- /dev/null
+++ b/app/helpers/ci/pipeline_schedules_helper.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Ci
+ module PipelineSchedulesHelper
+ def js_pipeline_schedules_form_data(project, schedule)
+ {
+ full_path: project.full_path,
+ daily_limit: schedule.daily_limit,
+ timezone_data: timezone_data.to_json,
+ project_id: project.id,
+ default_branch: project.default_branch,
+ settings_link: project_settings_ci_cd_path(project),
+ schedules_path: pipeline_schedules_path(project)
+ }
+ end
+ end
+end
+
+Ci::PipelineSchedulesHelper.prepend_mod_with('Ci::PipelineSchedulesHelper')
diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb
index b222ca5538d..a034e4331c0 100644
--- a/app/helpers/ci/pipelines_helper.rb
+++ b/app/helpers/ci/pipelines_helper.rb
@@ -68,18 +68,6 @@ module Ci
]
end
- def has_pipeline_badges?(pipeline)
- pipeline.schedule? ||
- pipeline.child? ||
- pipeline.latest? ||
- pipeline.merge_train_pipeline? ||
- pipeline.has_yaml_errors? ||
- pipeline.failure_reason? ||
- pipeline.auto_devops_source? ||
- pipeline.detached_merge_request_pipeline? ||
- pipeline.stuck?
- end
-
def pipelines_list_data(project, list_url)
artifacts_endpoint_placeholder = ':pipeline_artifacts_id'
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index 458d81b3401..5c410a28229 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -57,12 +57,6 @@ module ClustersHelper
render_if_exists 'clusters/clusters/environments'
when 'apps'
render 'applications'
- when 'integrations'
- if Feature.enabled?(:remove_monitor_metrics)
- render('details', expanded: expanded)
- else
- render 'integrations'
- end
when 'settings'
render 'advanced_settings_container'
else
diff --git a/app/helpers/colors_helper.rb b/app/helpers/colors_helper.rb
index bc72122220a..80cf6f197e5 100644
--- a/app/helpers/colors_helper.rb
+++ b/app/helpers/colors_helper.rb
@@ -4,7 +4,9 @@ module ColorsHelper
HEX_COLOR_PATTERN = /\A\#(?:[0-9A-Fa-f]{3}){1,2}\Z/.freeze
def hex_color_to_rgb_array(hex_color)
- raise ArgumentError, "invalid hex color `#{hex_color}`" unless hex_color =~ HEX_COLOR_PATTERN
+ unless hex_color.is_a?(String) && HEX_COLOR_PATTERN.match?(hex_color)
+ raise ArgumentError, "invalid hex color `#{hex_color}`"
+ end
hex_color.length == 7 ? hex_color[1, 7].scan(/.{2}/).map(&:hex) : hex_color[1, 4].scan(/./).map { |v| (v * 2).hex }
end
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index 9f4ed6b8150..7213bd074fc 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -41,7 +41,7 @@ module EmailsHelper
end
def sanitize_name(name)
- if name =~ URI::DEFAULT_PARSER.regexp[:URI_REF]
+ if URI::DEFAULT_PARSER.regexp[:URI_REF].match?(name)
name.tr('.', '_')
else
name
diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb
index 00109212934..8140ee97291 100644
--- a/app/helpers/environment_helper.rb
+++ b/app/helpers/environment_helper.rb
@@ -79,7 +79,6 @@ module EnvironmentHelper
can_destroy_environment: can_destroy_environment?(environment),
can_stop_environment: can?(current_user, :stop_environment, environment),
can_admin_environment: can?(current_user, :admin_environment, project),
- **environment_metrics_path(project, environment),
environments_fetch_path: project_environments_path(project, format: :json),
environment_edit_path: edit_project_environment_path(project, environment),
environment_stop_path: stop_project_environment_path(project, environment),
@@ -96,10 +95,4 @@ module EnvironmentHelper
def environments_detail_data_json(user, project, environment)
environments_detail_data(user, project, environment).to_json
end
-
- def environment_metrics_path(project, environment)
- return {} if Feature.enabled?(:remove_monitor_metrics)
-
- { environment_metrics_path: project_metrics_dashboard_path(project, environment: environment) }
- end
end
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 525fdd3e9f6..3360a5256af 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -26,7 +26,7 @@ module EnvironmentsHelper
metrics_data = {}
metrics_data.merge!(project_metrics_data(project)) if project
- metrics_data.merge!(environment_metrics_data(environment, project)) if environment
+ metrics_data.merge!(environment_metrics_data(environment)) if environment
metrics_data.merge!(project_and_environment_metrics_data(project, environment)) if project && environment
metrics_data.merge!(static_metrics_data)
@@ -46,14 +46,6 @@ module EnvironmentsHelper
can?(current_user, :destroy_environment, environment)
end
- def environment_data(environment)
- Gitlab::Json.generate({
- id: environment.id,
- name: environment.name,
- external_url: environment.external_url
- })
- end
-
private
def project_metrics_data(project)
@@ -74,34 +66,20 @@ module EnvironmentsHelper
}
end
- def environment_metrics_data(environment, project = nil)
+ def environment_metrics_data(environment)
return {} unless environment
{
- 'metrics_dashboard_base_path' => metrics_dashboard_base_path(environment, project),
'current_environment_name' => environment.name,
'has_metrics' => environment.has_metrics?.to_s,
'environment_state' => environment.state.to_s
}
end
- def metrics_dashboard_base_path(environment, project)
- # This is needed to support our transition from environment scoped metric paths to project scoped.
- if project
- path = project_metrics_dashboard_path(project)
-
- return path if request.path.include?(path)
- end
-
- project_metrics_dashboard_path(project, environment: environment)
- end
-
def project_and_environment_metrics_data(project, environment)
return {} unless project && environment
{
- 'metrics_endpoint' => additional_metrics_project_environment_path(project, environment, format: :json),
- 'dashboard_endpoint' => metrics_dashboard_project_environment_path(project, environment, format: :json),
'deployments_endpoint' => project_environment_deployments_path(project, environment, format: :json),
'operations_settings_path' => project_settings_operations_path(project),
'can_access_operations_settings' => can?(current_user, :admin_operations, project).to_s,
diff --git a/app/helpers/feed_token_helper.rb b/app/helpers/feed_token_helper.rb
new file mode 100644
index 00000000000..751a8df4782
--- /dev/null
+++ b/app/helpers/feed_token_helper.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module FeedTokenHelper
+ def generate_feed_token(type)
+ feed_token = current_user&.feed_token
+ return unless feed_token
+
+ final_path = "#{current_request.path}.#{type}"
+ digest = OpenSSL::HMAC.hexdigest("SHA256", feed_token, final_path)
+ "#{User::FEED_TOKEN_PREFIX}#{digest}-#{current_user.id}"
+ end
+end
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index 3d0b899e867..d5f38debae4 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -165,8 +165,8 @@ module FormHelper
def multiple_assignees_dropdown_options(options)
new_options = options.dup
- new_options[:title] = _('Select assignee(s)')
- new_options[:data][:'dropdown-header'] = 'Assignee(s)'
+ new_options[:title] = _('Select assignees')
+ new_options[:data][:'dropdown-header'] = 'Assignees'
new_options[:data][:'max-select'] = ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS
new_options
@@ -175,8 +175,8 @@ module FormHelper
def multiple_reviewers_dropdown_options(options)
new_options = options.dup
- new_options[:title] = _('Select reviewer(s)')
- new_options[:data][:'dropdown-header'] = _('Reviewer(s)')
+ new_options[:title] = _('Select reviewers')
+ new_options[:data][:'dropdown-header'] = _('Reviewers')
new_options[:data][:'max-select'] = ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index a4f463a23be..e552b01f7ba 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -25,6 +25,10 @@ module GroupsHelper
Ability.allowed?(current_user, :admin_group_member, group)
end
+ def can_admin_service_accounts?(group)
+ false
+ end
+
def group_icon_url(group, options = {})
if group.is_a?(String)
group = Group.find_by_full_path(group)
@@ -143,6 +147,7 @@ module GroupsHelper
def group_overview_tabs_app_data(group)
{
+ group_id: group.id,
subgroups_and_projects_endpoint: group_children_path(group, format: :json),
shared_projects_endpoint: group_shared_projects_path(group, format: :json),
archived_projects_endpoint: group_children_path(group, format: :json, archived: 'only'),
diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb
index ffea23bf55d..4b5fadf3397 100644
--- a/app/helpers/integrations_helper.rb
+++ b/app/helpers/integrations_helper.rb
@@ -30,6 +30,10 @@ module IntegrationsHelper
_("Alert")
when "incident"
_("Incident")
+ when "group_mention"
+ _("Group mention in public")
+ when "group_confidential_mention"
+ _("Group mention in private")
end
end
# rubocop:enable Metrics/CyclomaticComplexity
@@ -290,6 +294,10 @@ module IntegrationsHelper
s_("ProjectService|Trigger event when a new, unique alert is recorded.")
when "incident"
s_("ProjectService|Trigger event when an incident is created.")
+ when "group_mention"
+ s_("ProjectService|Trigger event when a group is mentioned in a public context.")
+ when "group_confidential_mention"
+ s_("ProjectService|Trigger event when a group is mentioned in a confidential context.")
end
end
# rubocop:enable Metrics/CyclomaticComplexity
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index e247577aed0..e921e9bae4d 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -43,14 +43,10 @@ module IssuablesHelper
due_date_with_remaining_days(milestone[:due_date], milestone[:start_date])
end
- def sidebar_due_date_tooltip_label(due_date)
- [_('Due date'), due_date_with_remaining_days(due_date)].compact.join('<br/>')
- end
-
def due_date_with_remaining_days(due_date, start_date = nil)
return unless due_date
- "#{due_date.to_s(:medium)} (#{remaining_days_in_words(due_date, start_date)})"
+ "#{due_date.to_fs(:medium)} (#{remaining_days_in_words(due_date, start_date)})"
end
def multi_label_name(current_labels, default_label)
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 341c50abf84..d9b9b27d16c 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -231,13 +231,15 @@ module IssuesHelper
can_read_crm_organization: can?(current_user, :read_crm_organization, group).to_s,
has_any_issues: @has_issues.to_s,
has_any_projects: @has_projects.to_s,
- new_project_path: new_project_path(namespace_id: group.id)
+ new_project_path: new_project_path(namespace_id: group.id),
+ group_id: group.id
)
end
def dashboard_issues_list_data(current_user)
{
autocomplete_award_emojis_path: autocomplete_award_emojis_path,
+ autocomplete_users_path: autocomplete_users_path,
calendar_path: url_for(safe_params.merge(calendar_url_options)),
dashboard_labels_path: dashboard_labels_path(format: :json, include_ancestor_groups: true),
dashboard_milestones_path: dashboard_milestones_path(format: :json),
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index 01030690daf..ff5e4248d98 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -5,14 +5,6 @@ module NamespacesHelper
params.dig(:project, :namespace_id) || params[:namespace_id]
end
- def namespace_icon(namespace, size = 40)
- if namespace.is_a?(Group)
- group_icon_url(namespace)
- else
- avatar_icon_for_user(namespace.owner, size)
- end
- end
-
def cascading_namespace_settings_popover_data(attribute, group, settings_path_helper)
locked_by_ancestor = group.namespace_settings.public_send("#{attribute}_locked_by_ancestor?") # rubocop:disable GitlabSecurity/PublicSend
diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb
index 201007863b2..306c4d8694e 100644
--- a/app/helpers/nav/new_dropdown_helper.rb
+++ b/app/helpers/nav/new_dropdown_helper.rb
@@ -70,7 +70,7 @@ module Nav
id: 'new_issue',
title: _('New issue'),
href: new_project_issue_path(project),
- data: { track_action: 'click_link_new_issue', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', qa_selector: 'new_issue_link' }
+ data: { track_action: 'click_link_new_issue', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', testid: 'new_issue_link' }
)
)
end
@@ -116,7 +116,7 @@ module Nav
id: 'general_new_project',
title: _('New project/repository'),
href: new_project_path,
- data: { track_action: 'click_link_new_project', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', qa_selector: 'global_new_project_link' }
+ data: { track_action: 'click_link_new_project', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', testid: 'global_new_project_link' }
)
)
end
@@ -127,7 +127,7 @@ module Nav
id: 'general_new_group',
title: _('New group'),
href: new_group_path,
- data: { track_action: 'click_link_new_group', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', qa_selector: 'global_new_group_link' }
+ data: { track_action: 'click_link_new_group', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', testid: 'global_new_group_link' }
)
)
end
@@ -138,7 +138,7 @@ module Nav
id: 'general_new_snippet',
title: _('New snippet'),
href: new_snippet_path,
- data: { track_action: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', qa_selector: 'global_new_snippet_link' }
+ data: { track_action: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', testid: 'global_new_snippet_link' }
)
)
end
diff --git a/app/helpers/nav/top_nav_helper.rb b/app/helpers/nav/top_nav_helper.rb
index c41cf7f500f..d74efac76aa 100644
--- a/app/helpers/nav/top_nav_helper.rb
+++ b/app/helpers/nav/top_nav_helper.rb
@@ -109,7 +109,7 @@ module Nav
builder.add_primary_menu_item_with_shortcut(
header: top_nav_localized_headers[:switch_to],
active: nav == 'project' || active_nav_link?(path: %w[root#index projects#trending projects#starred dashboard/projects#index]),
- data: { track_label: "projects_dropdown", track_action: "click_dropdown", track_property: "navigation_top", qa_selector: "projects_dropdown" },
+ data: { track_label: "projects_dropdown", track_action: "click_dropdown", track_property: "navigation_top", testid: "projects_dropdown" },
view: PROJECTS_VIEW,
shortcut_href: dashboard_projects_path,
**projects_menu_item_attrs
@@ -123,7 +123,7 @@ module Nav
builder.add_primary_menu_item_with_shortcut(
header: top_nav_localized_headers[:switch_to],
active: nav == 'group' || active_nav_link?(path: %w[dashboard/groups explore/groups]),
- data: { track_label: "groups_dropdown", track_action: "click_dropdown", track_property: "navigation_top", qa_selector: "groups_dropdown" },
+ data: { track_label: "groups_dropdown", track_action: "click_dropdown", track_property: "navigation_top", testid: "groups_dropdown" },
view: GROUPS_VIEW,
shortcut_href: dashboard_groups_path,
**groups_menu_item_attrs
@@ -218,7 +218,7 @@ module Nav
active: active_nav_link?(controller: 'admin/sessions'),
icon: 'lock',
href: new_admin_session_path,
- data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) }
+ data: { testid: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) }
)
end
end
@@ -316,7 +316,7 @@ module Nav
id: 'your',
title: title,
href: dashboard_projects_path,
- data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) }
+ data: { testid: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) }
)
end
@@ -330,7 +330,7 @@ module Nav
id: 'your',
title: title,
href: dashboard_groups_path,
- data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) }
+ data: { testid: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) }
)
builder.build
end
diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb
index 8861f1ffe9a..31fcc77925b 100644
--- a/app/helpers/packages_helper.rb
+++ b/app/helpers/packages_helper.rb
@@ -74,6 +74,16 @@ module PackagesHelper
Ability.allowed?(current_user, :admin_group, group)
end
+ def can_delete_packages?(project)
+ Gitlab.config.packages.enabled &&
+ Ability.allowed?(current_user, :destroy_package, project)
+ end
+
+ def can_delete_group_packages?(group)
+ group.packages_feature_enabled? &&
+ Ability.allowed?(current_user, :destroy_package, group)
+ end
+
def cleanup_settings_data
{
project_id: @project.id,
diff --git a/app/helpers/projects/observability_helper.rb b/app/helpers/projects/observability_helper.rb
new file mode 100644
index 00000000000..24bc1928a36
--- /dev/null
+++ b/app/helpers/projects/observability_helper.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Projects
+ module ObservabilityHelper
+ def observability_tracing_view_model(project)
+ Gitlab::Json.generate({
+ tracingUrl: Gitlab::Observability.tracing_url(project),
+ provisioningUrl: Gitlab::Observability.provisioning_url(project),
+ oauthUrl: Gitlab::Observability.oauth_url
+ })
+ end
+ end
+end
diff --git a/app/helpers/projects/pages_helper.rb b/app/helpers/projects/pages_helper.rb
index f46c11db1db..d90ea0ec598 100644
--- a/app/helpers/projects/pages_helper.rb
+++ b/app/helpers/projects/pages_helper.rb
@@ -7,5 +7,17 @@ module Projects
(Gitlab.config.pages.external_http || Gitlab.config.pages.external_https) &&
project.can_create_custom_domains?
end
+
+ def pages_subdomain(project)
+ Gitlab::Pages::UrlBuilder
+ .new(project)
+ .project_namespace
+ end
+
+ def build_pages_url(project, with_unique_domain:)
+ Gitlab::Pages::UrlBuilder
+ .new(project)
+ .pages_url(with_unique_domain: with_unique_domain)
+ end
end
end
diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb
index caebbd5250e..42e8e44c94c 100644
--- a/app/helpers/projects/pipeline_helper.rb
+++ b/app/helpers/projects/pipeline_helper.rb
@@ -44,7 +44,7 @@ module Projects
failed: pipeline.failure_reason?.to_s,
auto_devops: pipeline.auto_devops_source?.to_s,
detached: pipeline.detached_merge_request_pipeline?.to_s,
- stuck: pipeline.stuck?,
+ stuck: pipeline.stuck?.to_s,
ref_text: pipeline.ref_text
}
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 9415e7d4dc3..e27ee1acb22 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -172,18 +172,6 @@ module ProjectsHelper
project.fork_source if project.fork_source && can?(current_user, :read_project, project.fork_source)
end
- def project_search_tabs?(tab)
- return false unless @project.present?
-
- abilities = Array(search_tab_ability_map[tab])
-
- if @project.respond_to?(:each) # support multi-project select
- @project.any? { |project| abilities.any? { |ability| can?(current_user, ability, project) } }
- else
- abilities.any? { |ability| can?(current_user, ability, @project) }
- end
- end
-
def can_change_visibility_level?(project, current_user)
can?(current_user, :change_visibility_level, project)
end
@@ -511,9 +499,9 @@ module ProjectsHelper
def clusters_deprecation_alert_message
if has_active_license?
- s_('ClusterIntegration|The certificate-based Kubernetes integration has been deprecated and will be turned off at the end of February 2023. Please %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd}. Contact GitLab Support if you have any additional questions.')
+ s_('ClusterIntegration|The certificate-based Kubernetes integration is deprecated and will be removed in the future. You should %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd}. For more information, see the %{deprecationLinkStart}deprecation epic%{deprecationLinkEnd}, or contact GitLab support.')
else
- s_('ClusterIntegration|The certificate-based Kubernetes integration has been deprecated and will be turned off at the end of February 2023. Please %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd}.')
+ s_('ClusterIntegration|The certificate-based Kubernetes integration is deprecated and will be removed in the future. You should %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd}. For more information, see the %{deprecationLinkStart}deprecation epic%{deprecationLinkEnd}.')
end
end
@@ -547,8 +535,32 @@ module ProjectsHelper
project.ssh_url_to_repo
end
+ def can_view_branch_rules?
+ can?(current_user, :maintainer_access, @project)
+ end
+
+ def can_push_code?
+ current_user&.can?(:push_code, @project)
+ end
+
+ def can_admin_associated_clusters?(project)
+ can_admin_project_clusters?(project) || can_admin_group_clusters?(project)
+ end
+
+ def branch_rules_path
+ project_settings_repository_path(@project, anchor: 'js-branch-rules')
+ end
+
private
+ def can_admin_project_clusters?(project)
+ project.clusters.any? && can?(current_user, :admin_cluster, project)
+ end
+
+ def can_admin_group_clusters?(project)
+ project.group && project.group.clusters.any? && can?(current_user, :admin_cluster, project.group)
+ end
+
def create_merge_request_path(project, source_project, ref, merge_request)
return if merge_request.present?
return unless can?(current_user, :create_merge_request_from, project)
@@ -590,41 +602,6 @@ module ProjectsHelper
s_(str).html_safe % { provider: provider, link_start: link_start, link_end: '</a>'.html_safe }
end
- def tab_ability_map
- {
- cycle_analytics: :read_cycle_analytics,
- environments: :read_environment,
- metrics_dashboards: :metrics_dashboard,
- milestones: :read_milestone,
- snippets: :read_snippet,
- settings: :admin_project,
- builds: :read_build,
- clusters: :read_cluster,
- serverless: :read_cluster,
- terraform: :read_terraform_state,
- error_tracking: :read_sentry_issue,
- alert_management: :read_alert_management_alert,
- incidents: :read_issue,
- labels: :read_label,
- issues: :read_issue,
- project_members: :read_project_member,
- wiki: :read_wiki,
- feature_flags: :read_feature_flag,
- analytics: :read_analytics
- }
- end
-
- def search_tab_ability_map
- @search_tab_ability_map ||= tab_ability_map.merge(
- blobs: :read_code,
- commits: :read_code,
- merge_requests: :read_merge_request,
- notes: [:read_merge_request, :read_code, :read_issue, :read_snippet],
- users: :read_project_member,
- wiki_blobs: :read_wiki
- )
- end
-
def project_lfs_status(project)
if project.lfs_enabled?
content_tag(:span, class: 'lfs-enabled') do
@@ -880,24 +857,4 @@ module ProjectsHelper
end
end
-def can_admin_associated_clusters?(project)
- can_admin_project_clusters?(project) || can_admin_group_clusters?(project)
-end
-
-def can_admin_project_clusters?(project)
- project.clusters.any? && can?(current_user, :admin_cluster, project)
-end
-
-def can_admin_group_clusters?(project)
- project.group && project.group.clusters.any? && can?(current_user, :admin_cluster, project.group)
-end
-
-def can_view_branch_rules?
- can?(current_user, :maintainer_access, @project)
-end
-
-def branch_rules_path
- project_settings_repository_path(@project, anchor: 'js-branch-rules')
-end
-
ProjectsHelper.prepend_mod_with('ProjectsHelper')
diff --git a/app/helpers/rss_helper.rb b/app/helpers/rss_helper.rb
index 67c7d244f11..90dd4e8fedb 100644
--- a/app/helpers/rss_helper.rb
+++ b/app/helpers/rss_helper.rb
@@ -2,6 +2,6 @@
module RssHelper
def rss_url_options
- { format: :atom, feed_token: current_user.try(:feed_token) }
+ { format: :atom, feed_token: generate_feed_token(:atom) }
end
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 8fbbd18c9ae..cd32023adb6 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -373,17 +373,10 @@ module SearchHelper
def users_autocomplete(term, limit = 5)
return [] unless current_user && Ability.allowed?(current_user, :read_users_list)
- users = if Feature.enabled?(:autocomplete_users_use_search_service)
- ::SearchService
- .new(current_user, { scope: 'users', per_page: limit, search: term })
- .search_objects
- else
- is_current_user_admin = current_user.can_admin_all_resources?
- scope = is_current_user_admin ? User.all : User.without_forbidden_states
- scope.search(term, with_private_emails: is_current_user_admin, use_minimum_char_limit: false).limit(limit)
- end
-
- users.map do |user|
+ ::SearchService
+ .new(current_user, { scope: 'users', per_page: limit, search: term })
+ .search_objects
+ .map do |user|
{
category: "Users",
id: user.id,
@@ -471,65 +464,15 @@ module SearchHelper
result
end
- def show_code_search_tab?
- return true if project_search_tabs?(:blobs)
-
- @project.nil? && search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_code_tab)
- end
-
- def show_wiki_search_tab?
- return true if project_search_tabs?(:wiki_blobs)
-
- @project.nil? && search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_wiki_tab)
- end
-
- def show_commits_search_tab?
- return true if project_search_tabs?(:commits)
-
- @project.nil? && search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_commits_tab)
- end
-
- def show_issues_search_tab?
- return true if project_search_tabs?(:issues)
-
- @project.nil? && feature_flag_tab_enabled?(:global_search_issues_tab)
- end
-
- def show_merge_requests_search_tab?
- return true if project_search_tabs?(:merge_requests)
-
- @project.nil? && feature_flag_tab_enabled?(:global_search_merge_requests_tab)
- end
-
- def show_comments_search_tab?
- return true if project_search_tabs?(:notes)
-
- @project.nil? && search_service.show_elasticsearch_tabs?
- end
-
- def show_snippets_search_tab?
- search_service.show_snippets? && @project.nil? && feature_flag_tab_enabled?(:global_search_snippet_titles_tab)
- end
-
- # search page scope navigation
- def search_navigation
+ def nav_options
{
- projects: { sort: 1, label: _("Projects"), data: { qa_selector: 'projects_tab' }, condition: @project.nil? },
- blobs: { sort: 2, label: _("Code"), data: { qa_selector: 'code_tab' }, condition: show_code_search_tab? },
- # sort: 3 is reserved for EE items
- issues: { sort: 4, label: _("Issues"), condition: show_issues_search_tab? },
- merge_requests: { sort: 5, label: _("Merge requests"), condition: show_merge_requests_search_tab? },
- wiki_blobs: { sort: 6, label: _("Wiki"), condition: show_wiki_search_tab? },
- commits: { sort: 7, label: _("Commits"), condition: show_commits_search_tab? },
- notes: { sort: 8, label: _("Comments"), condition: show_comments_search_tab? },
- milestones: { sort: 9, label: _("Milestones"), condition: project_search_tabs?(:milestones) || @project.nil? },
- users: { sort: 10, label: _("Users"), condition: show_user_search_tab? },
- snippet_titles: { sort: 11, label: _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil }, condition: show_snippets_search_tab? }
+ show_snippets: search_service.show_snippets?
}
end
def search_navigation_json
- sorted_navigation = search_navigation.sort_by { |_, h| h[:sort] }
+ search_navigation = Search::Navigation.new(user: current_user, project: @project, group: @group, options: nav_options)
+ sorted_navigation = search_navigation.tabs.sort_by { |_, h| h[:sort] }
sorted_navigation.each_with_object({}) do |(key, value), hash|
hash[key] = search_filter_link_json(key, value[:label], value[:data], value[:search]) if value[:condition]
@@ -611,14 +554,6 @@ module SearchHelper
simple_search_highlight_and_truncate(issuable.description, search_term, highlighter: '<span class="gl-text-gray-900 gl-font-weight-bold">\1</span>')
end
- def show_user_search_tab?
- return project_search_tabs?(:users) if @project
- return false unless can?(current_user, :read_users_list)
- return true if @group
-
- Feature.enabled?(:global_search_users_tab, current_user, type: :ops)
- end
-
def issuable_state_to_badge_class(issuable)
# Closed is considered "danger" for MR so we need to handle separately
if issuable.is_a?(::MergeRequest)
@@ -647,10 +582,6 @@ module SearchHelper
end
end
- def feature_flag_tab_enabled?(flag)
- @group.present? || Feature.enabled?(flag, current_user, type: :ops)
- end
-
def sanitized_search_params
sanitized_params = params.dup
@@ -664,6 +595,10 @@ module SearchHelper
sanitized_params
end
+
+ def wiki_blob_link(wiki_blob)
+ project_wiki_path(wiki_blob.project, wiki_blob.basename)
+ end
end
SearchHelper.prepend_mod_with('SearchHelper')
diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb
index 02a912d0227..90917cb96e0 100644
--- a/app/helpers/sidebars_helper.rb
+++ b/app/helpers/sidebars_helper.rb
@@ -23,6 +23,10 @@ module SidebarsHelper
end
end
+ def organization_sidebar_context(organization, user, **args)
+ Sidebars::Context.new(container: organization, current_user: user, **args)
+ end
+
def project_sidebar_context(project, user, current_ref, ref_type: nil, **args)
context_data = project_sidebar_context_data(project, user, current_ref, ref_type: ref_type)
Sidebars::Projects::Context.new(**context_data, **args)
@@ -95,7 +99,7 @@ module SidebarsHelper
def super_sidebar_nav_panel(
nav: nil, project: nil, user: nil, group: nil, current_ref: nil, ref_type: nil,
- viewed_user: nil)
+ viewed_user: nil, organization: nil)
context_adds = { route_is_active: method(:active_nav_link?), is_super_sidebar: true }
case nav
when 'project'
@@ -117,12 +121,25 @@ module SidebarsHelper
Sidebars::Search::Panel.new(context)
when 'admin'
Sidebars::Admin::Panel.new(Sidebars::Context.new(current_user: user, container: nil, **context_adds))
+ when 'organization'
+ context = organization_sidebar_context(organization, user, **context_adds)
+ Sidebars::Organizations::SuperSidebarPanel.new(context)
else
context = your_work_sidebar_context(user, **context_adds)
Sidebars::YourWork::Panel.new(context)
end
end
+ def command_palette_data(project: nil)
+ return {} unless project&.repo_exists?
+ return {} if project.empty_repo?
+
+ {
+ project_files_url: project_files_path(project, project.default_branch, format: :json),
+ project_blob_url: project_blob_path(project, project.default_branch)
+ }
+ end
+
private
def search_data
@@ -142,7 +159,8 @@ module SidebarsHelper
customized: user.status&.customized?,
availability: user.status&.availability.to_s,
emoji: user.status&.emoji,
- message: user.status&.message_html&.html_safe,
+ message_html: user.status&.message_html&.html_safe,
+ message: user.status&.message,
clear_after: user_clear_status_at(user)
}
end
@@ -162,7 +180,7 @@ module SidebarsHelper
'data-track-label': item[:id],
'data-track-action': 'click_link',
'data-track-property': 'nav_create_menu',
- 'data-qa-selector': 'create_menu_item',
+ 'data-testid': 'create_menu_item',
'data-qa-create-menu-item': item[:id]
}
}
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 9038d972f65..1405bc7be37 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -2,6 +2,7 @@
module SortingHelper
include SortingTitlesValuesHelper
+ include ButtonHelper
# rubocop: disable Metrics/AbcSize
def sort_options_hash
@@ -167,10 +168,6 @@ module SortingHelper
}
end
- def sortable_item(item, path, sorted_by)
- link_to item, path, class: sorted_by == item ? 'is-active' : ''
- end
-
def issuable_sort_option_overrides
{
sort_value_oldest_created => sort_value_created_date,
@@ -275,7 +272,7 @@ module SortingHelper
end
def sort_direction_button(reverse_url, reverse_sort, sort_value)
- link_class = 'gl-button btn btn-default btn-icon has-tooltip reverse-sort-btn rspec-reverse-sort'
+ link_class = 'has-tooltip reverse-sort-btn rspec-reverse-sort'
icon = sort_direction_icon(sort_value)
url = reverse_url
@@ -284,9 +281,7 @@ module SortingHelper
link_class += ' disabled'
end
- link_to(url, type: 'button', class: link_class, title: s_('SortOptions|Sort direction')) do
- sprite_icon(icon)
- end
+ link_button_to nil, url, class: link_class, title: s_('SortOptions|Sort direction'), icon: icon
end
def issuable_sort_direction_button(sort_value)
diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb
index a60143db739..669d13c14c2 100644
--- a/app/helpers/storage_helper.rb
+++ b/app/helpers/storage_helper.rb
@@ -21,6 +21,10 @@ module StorageHelper
counter_uploads: storage_counter(statistics.uploads_size)
}
- _("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / Pipeline Artifacts: %{counter_pipeline_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets} / Packages: %{counter_packages} / Uploads: %{counter_uploads}") % counters
+ _(
+ "Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / " \
+ "Pipeline Artifacts: %{counter_pipeline_artifacts} / LFS: %{counter_lfs_objects} / " \
+ "Snippets: %{counter_snippets} / Packages: %{counter_packages} / Uploads: %{counter_uploads}"
+ ) % counters
end
end
diff --git a/app/helpers/time_helper.rb b/app/helpers/time_helper.rb
index cb6f60ab79b..ad473875a53 100644
--- a/app/helpers/time_helper.rb
+++ b/app/helpers/time_helper.rb
@@ -17,10 +17,6 @@ module TimeHelper
end
end
- def date_from_to(from, to)
- "#{from.to_s(:short)} - #{to.to_s(:short)}"
- end
-
def duration_in_numbers(duration_in_seconds)
seconds = duration_in_seconds % 1.minute
minutes = (duration_in_seconds / 1.minute) % (1.hour / 1.minute)
diff --git a/app/helpers/timeboxes_helper.rb b/app/helpers/timeboxes_helper.rb
index 66c9011fbcc..cb6ed059ec9 100644
--- a/app/helpers/timeboxes_helper.rb
+++ b/app/helpers/timeboxes_helper.rb
@@ -109,7 +109,7 @@ module TimeboxesHelper
content = [
title,
"<br />",
- date.to_s(:medium),
+ date.to_fs(:medium),
"(#{time_ago} #{state})"
].join(" ")
@@ -172,7 +172,7 @@ module TimeboxesHelper
def milestone_tooltip_due_date(milestone)
if milestone.due_date
- "#{milestone.due_date.to_s(:medium)} (#{remaining_days_in_words(milestone.due_date, milestone.start_date)})"
+ "#{milestone.due_date.to_fs(:medium)} (#{remaining_days_in_words(milestone.due_date, milestone.start_date)})"
else
_('Milestone')
end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index acc7d8a5a10..29998a996e2 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -12,7 +12,7 @@ module UsersHelper
# The user.status can be nil when the user has no status, so we need to protect against that case.
# iso8601 is the official RFC supported format for frontend parsing of date:
# https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/Date
- user.status&.clear_status_at&.to_s(:iso8601)
+ user.status&.clear_status_at&.to_fs(:iso8601)
end
def user_link(user)
@@ -190,7 +190,9 @@ module UsersHelper
user_activity_path: user_activity_path(user, :json),
utc_offset: local_timezone_instance(user.timezone).now.utc_offset,
user_id: user.id,
- snippets_empty_state: image_path('illustrations/empty-state/empty-snippets-md.svg')
+ snippets_empty_state: image_path('illustrations/empty-state/empty-snippets-md.svg'),
+ new_snippet_path: (new_snippet_path if can?(current_user, :create_snippet)),
+ follow_empty_state: image_path('illustrations/empty-state/empty-friends-md.svg')
}
end
diff --git a/app/helpers/web_ide_button_helper.rb b/app/helpers/web_ide_button_helper.rb
index 9ec22a659d3..185e1b8e0a8 100644
--- a/app/helpers/web_ide_button_helper.rb
+++ b/app/helpers/web_ide_button_helper.rb
@@ -33,10 +33,6 @@ module WebIdeButtonHelper
can_view_pipeline_editor?(project) && path == project.ci_config_path_or_default
end
- def can_push_code?
- current_user&.can?(:push_code, @project)
- end
-
def fork?
!project_fork.nil? && !can_push_code?
end
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index 0328d262dc7..52a16475c07 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -190,7 +190,7 @@ module Emails
to: @recipient.notification_email_for(@project.group),
subject: subject("#{@issue.title} (##{@issue.iid})"),
'X-GitLab-NotificationReason' => reason,
- 'X-GitLab-ConfidentialIssue' => confidentiality
+ 'X-GitLab-ConfidentialIssue' => confidentiality.to_s
}
end
end
diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb
index 1e254a32885..bdd63dfc62c 100644
--- a/app/mailers/emails/notes.rb
+++ b/app/mailers/emails/notes.rb
@@ -15,7 +15,14 @@ module Emails
@issue = @note.noteable
@target_url = project_issue_url(*note_target_url_options)
- mail_answer_note_thread(@issue, @note, note_thread_options(reason))
+ mail_answer_note_thread(
+ @issue,
+ @note,
+ note_thread_options(
+ reason,
+ confidentiality: @issue.confidential?
+ )
+ )
end
def note_merge_request_email(recipient_id, note_id, reason = nil)
@@ -62,13 +69,15 @@ module Emails
{ anchor: "note_#{@note.id}" }
end
- def note_thread_options(reason)
+ def note_thread_options(reason, confidentiality: nil)
{
from: sender(@note.author_id),
to: @recipient.notification_email_for(@project&.group || @group),
subject: subject("#{@note.noteable.title} (#{@note.noteable.reference_link_text})"),
'X-GitLab-NotificationReason' => reason
- }
+ }.tap do |options|
+ options['X-GitLab-ConfidentialIssue'] = confidentiality.to_s unless confidentiality.nil?
+ end
end
def setup_note_mail(note_id, recipient_id)
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index 54a4c4be6a8..a382ca15e46 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -65,9 +65,7 @@ module Emails
@target_url = profile_personal_access_tokens_url
@token_name = token_name
- Gitlab::I18n.with_locale(@user.preferred_language) do
- mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("A new personal access token has been created")))
- end
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("A new personal access token has been created")))
end
def access_token_about_to_expire_email(user, token_names)
@@ -78,9 +76,7 @@ module Emails
@target_url = profile_personal_access_tokens_url
@days_to_expire = PersonalAccessToken::DAYS_TO_EXPIRE
- Gitlab::I18n.with_locale(@user.preferred_language) do
- mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your personal access tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire }))
- end
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your personal access tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire }))
end
def access_token_expired_email(user, token_names = [])
@@ -90,9 +86,7 @@ module Emails
@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 tokens have expired")))
- end
+ email_with_layout(to: @user.notification_email_or_default, subject: subject(_("Your personal access tokens have expired")))
end
def access_token_revoked_email(user, token_name, source = nil)
@@ -103,9 +97,7 @@ module Emails
@target_url = profile_personal_access_tokens_url
@source = source
- Gitlab::I18n.with_locale(@user.preferred_language) do
- mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("A personal access token has been revoked")))
- end
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("A personal access token has been revoked")))
end
def ssh_key_expired_email(user, fingerprints)
@@ -115,9 +107,7 @@ module Emails
@fingerprints = fingerprints
@target_url = profile_keys_url
- Gitlab::I18n.with_locale(@user.preferred_language) do
- mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your SSH key has expired")))
- end
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your SSH key has expired")))
end
def ssh_key_expiring_soon_email(user, fingerprints)
@@ -127,9 +117,7 @@ module Emails
@fingerprints = fingerprints
@target_url = profile_keys_url
- Gitlab::I18n.with_locale(@user.preferred_language) do
- mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your SSH key is expiring soon.")))
- end
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your SSH key is expiring soon.")))
end
def unknown_sign_in_email(user, ip, time)
@@ -138,11 +126,9 @@ module Emails
@time = time
@target_url = edit_profile_password_url
- Gitlab::I18n.with_locale(@user.preferred_language) do
- email_with_layout(
- to: @user.notification_email_or_default,
- subject: subject(_("%{host} sign-in from new location") % { host: Gitlab.config.gitlab.host }))
- end
+ email_with_layout(
+ to: @user.notification_email_or_default,
+ subject: subject(_("%{host} sign-in from new location") % { host: Gitlab.config.gitlab.host }))
end
def two_factor_otp_attempt_failed_email(user, ip, time = Time.current)
@@ -150,11 +136,9 @@ module Emails
@ip = ip
@time = time
- Gitlab::I18n.with_locale(@user.preferred_language) do
- email_with_layout(
- to: @user.notification_email_or_default,
- subject: subject(_("Attempted sign in to %{host} using an incorrect verification code") % { host: Gitlab.config.gitlab.host }))
- end
+ email_with_layout(
+ to: @user.notification_email_or_default,
+ subject: subject(_("Attempted sign in to %{host} using an incorrect verification code") % { host: Gitlab.config.gitlab.host }))
end
def disabled_two_factor_email(user)
@@ -162,9 +146,7 @@ module Emails
@user = user
- Gitlab::I18n.with_locale(@user.preferred_language) do
- mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Two-factor authentication disabled")))
- end
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Two-factor authentication disabled")))
end
def new_email_address_added_email(user, email)
@@ -173,9 +155,7 @@ module Emails
@user = user
@email = email
- Gitlab::I18n.with_locale(@user.preferred_language) do
- mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("New email address added")))
- end
+ mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("New email address added")))
end
def new_achievement_email(user, achievement)
@@ -184,11 +164,9 @@ module Emails
@user = user
@achievement = achievement
- Gitlab::I18n.with_locale(@user.preferred_language) do
- email_with_layout(
- to: @user.notification_email_or_default,
- subject: subject(s_("Achievements|%{namespace_full_path} awarded you the %{achievement_name} achievement") % { namespace_full_path: @achievement.namespace.full_path, achievement_name: @achievement.name }))
- end
+ email_with_layout(
+ to: @user.notification_email_or_default,
+ subject: subject(s_("Achievements|%{namespace_full_path} awarded you the %{achievement_name} achievement") % { namespace_full_path: @achievement.namespace.full_path, achievement_name: @achievement.name }))
end
end
end
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index d91f69cdd4b..576dbdd8b52 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -64,6 +64,11 @@ class NotifyPreview < ActionMailer::Preview
Notify.access_token_created_email(user, 'token_name').message
end
+ def access_token_expired_email
+ token_names = []
+ Notify.access_token_expired_email(user, token_names).message
+ end
+
def access_token_revoked_email
Notify.access_token_revoked_email(user, 'token_name').message
end
diff --git a/app/models/abuse/trust_score.rb b/app/models/abuse/trust_score.rb
index b7ed504a0ba..2e8b7ed6686 100644
--- a/app/models/abuse/trust_score.rb
+++ b/app/models/abuse/trust_score.rb
@@ -2,9 +2,6 @@
module Abuse
class TrustScore < ApplicationRecord
- MAX_EVENTS = 100
- SPAMCHECK_HAM_THRESHOLD = 0.5
-
self.table_name = 'abuse_trust_scores'
enum source: Enums::Abuse::Source.sources
@@ -15,6 +12,9 @@ module Abuse
validates :score, presence: true
validates :source, presence: true
+ scope :order_created_at_asc, -> { order(created_at: :asc) }
+ scope :order_created_at_desc, -> { order(created_at: :desc) }
+
before_create :assign_correlation_id
after_commit :remove_old_scores
@@ -25,14 +25,7 @@ module Abuse
end
def remove_old_scores
- count = user.trust_scores_for_source(source).count
- return unless count > MAX_EVENTS
-
- TrustScore.delete(
- user.trust_scores_for_source(source)
- .order(created_at: :asc)
- .limit(count - MAX_EVENTS)
- )
+ Abuse::UserTrustScore.new(user).remove_old_scores(source)
end
end
end
diff --git a/app/models/abuse/user_trust_score.rb b/app/models/abuse/user_trust_score.rb
new file mode 100644
index 00000000000..3a935e230ae
--- /dev/null
+++ b/app/models/abuse/user_trust_score.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module Abuse
+ class UserTrustScore
+ MAX_EVENTS = 100
+ SPAMCHECK_HAM_THRESHOLD = 0.5
+
+ def initialize(user)
+ @user = user
+ end
+
+ def spammer?
+ spam_score > SPAMCHECK_HAM_THRESHOLD
+ end
+
+ def spam_score
+ user_scores.spamcheck.average(:score) || 0.0
+ end
+
+ def telesign_score
+ user_scores.telesign.order_created_at_desc.first&.score || 0.0
+ end
+
+ def arkose_global_score
+ user_scores.arkose_global_score.order_created_at_desc.first&.score || 0.0
+ end
+
+ def arkose_custom_score
+ user_scores.arkose_custom_score.order_created_at_desc.first&.score || 0.0
+ end
+
+ def trust_scores_for_source(source)
+ user_scores.where(source: source)
+ end
+
+ def remove_old_scores(source)
+ count = trust_scores_for_source(source).count
+ return unless count > MAX_EVENTS
+
+ Abuse::TrustScore.delete(
+ trust_scores_for_source(source)
+ .order_created_at_asc
+ .limit(count - MAX_EVENTS)
+ )
+ end
+
+ private
+
+ def user_scores
+ Abuse::TrustScore.where(user_id: @user.id)
+ end
+ end
+end
diff --git a/app/models/ai/service_access_token.rb b/app/models/ai/service_access_token.rb
new file mode 100644
index 00000000000..863bdfc7899
--- /dev/null
+++ b/app/models/ai/service_access_token.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Ai
+ class ServiceAccessToken < ApplicationRecord
+ self.table_name = 'service_access_tokens'
+
+ scope :expired, -> { where('expires_at < :now', now: Time.current) }
+ scope :for_category, ->(category) { where(category: category) }
+
+ attr_encrypted :token,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_32,
+ algorithm: 'aes-256-gcm',
+ encode: false,
+ encode_iv: false
+
+ validates :token, :expires_at, presence: true
+
+ enum category: {
+ code_suggestions: 1
+ }
+
+ validates :category, presence: true
+ end
+end
diff --git a/app/models/alert_management/http_integration.rb b/app/models/alert_management/http_integration.rb
index d5162865a79..a70168dc0d8 100644
--- a/app/models/alert_management/http_integration.rb
+++ b/app/models/alert_management/http_integration.rb
@@ -4,7 +4,7 @@ module AlertManagement
class HttpIntegration < ApplicationRecord
include ::Gitlab::Routing
- LEGACY_IDENTIFIER = 'legacy'
+ LEGACY_IDENTIFIERS = %w[legacy legacy-prometheus].freeze
belongs_to :project, inverse_of: :alert_management_http_integrations
@@ -20,8 +20,8 @@ module AlertManagement
validates :token, presence: true, format: { with: /\A\h{32}\z/ }
validates :name, presence: true, length: { maximum: 255 }
validates :type_identifier, presence: true
- validates :endpoint_identifier, presence: true, length: { maximum: 255 }, format: { with: /\A[A-Za-z0-9]+\z/ }
- validates :endpoint_identifier, uniqueness: { scope: [:project_id, :active] }, if: :active?
+ validates :endpoint_identifier, presence: true, length: { maximum: 255 }, format: { with: /\A[A-Za-z0-9-]+\z/ }
+ validates :endpoint_identifier, uniqueness: { scope: [:project_id] }
validates :payload_attribute_mapping, json_schema: { filename: 'http_integration_payload_attribute_mapping' }
before_validation :prevent_token_assignment
@@ -33,7 +33,6 @@ module AlertManagement
scope :for_type, ->(type) { where(type_identifier: type) }
scope :for_project, ->(project_ids) { where(project: project_ids) }
scope :active, -> { where(active: true) }
- scope :legacy, -> { for_endpoint_identifier(LEGACY_IDENTIFIER) }
scope :ordered_by_type_and_id, -> { order(:type_identifier, :id) }
enum type_identifier: {
@@ -42,16 +41,18 @@ module AlertManagement
}
def url
- if legacy?
- return project_alerts_notify_url(project, format: :json) if http?
- return notify_project_prometheus_alerts_url(project, format: :json) if prometheus?
+ case endpoint_identifier
+ when 'legacy'
+ project_alerts_notify_url(project, format: :json)
+ when 'legacy-prometheus'
+ notify_project_prometheus_alerts_url(project, format: :json)
+ else
+ project_alert_http_integration_url(project, name_slug, endpoint_identifier, format: :json)
end
-
- project_alert_http_integration_url(project, name_slug, endpoint_identifier, format: :json)
end
def legacy?
- endpoint_identifier == LEGACY_IDENTIFIER
+ LEGACY_IDENTIFIERS.include?(endpoint_identifier)
end
private
diff --git a/app/models/analytics/cycle_analytics/stage.rb b/app/models/analytics/cycle_analytics/stage.rb
index c7bff7c8d7f..6f152e7749e 100644
--- a/app/models/analytics/cycle_analytics/stage.rb
+++ b/app/models/analytics/cycle_analytics/stage.rb
@@ -3,6 +3,8 @@
module Analytics
module CycleAnalytics
class Stage < ApplicationRecord
+ MAX_STAGES_PER_VALUE_STREAM = 15
+
self.table_name = :analytics_cycle_analytics_group_stages
include DatabaseEventTracking
@@ -10,6 +12,8 @@ module Analytics
include Analytics::CycleAnalytics::Parentable
validates :name, uniqueness: { scope: [:group_id, :group_value_stream_id] }
+ validate :max_stages_count, on: :create
+
belongs_to :value_stream, class_name: 'Analytics::CycleAnalytics::ValueStream',
foreign_key: :group_value_stream_id, inverse_of: :stages
@@ -49,6 +53,15 @@ module Analytics
name
group_value_stream_id
].freeze
+
+ private
+
+ def max_stages_count
+ return unless value_stream
+ return unless value_stream.stages.count >= MAX_STAGES_PER_VALUE_STREAM
+
+ errors.add(:value_stream, _('Maximum number of stages per value stream exceeded'))
+ end
end
end
end
diff --git a/app/models/analytics/cycle_analytics/value_stream.rb b/app/models/analytics/cycle_analytics/value_stream.rb
index 31e06075bcb..16446a5b463 100644
--- a/app/models/analytics/cycle_analytics/value_stream.rb
+++ b/app/models/analytics/cycle_analytics/value_stream.rb
@@ -3,6 +3,8 @@
module Analytics
module CycleAnalytics
class ValueStream < ApplicationRecord
+ MAX_VALUE_STREAMS_PER_NAMESPACE = 50
+
self.table_name = :analytics_cycle_analytics_group_value_streams
include Analytics::CycleAnalytics::Parentable
@@ -15,6 +17,7 @@ module Analytics
validates :name, presence: true
validates :name, length: { minimum: 3, maximum: 100, allow_nil: false }, uniqueness: { scope: :group_id }
+ validate :max_value_streams_count, on: :create
accepts_nested_attributes_for :stages, allow_destroy: true
@@ -35,6 +38,13 @@ module Analytics
private
+ def max_value_streams_count
+ return unless namespace
+ return unless namespace.value_streams.count >= MAX_VALUE_STREAMS_PER_NAMESPACE
+
+ errors.add(:namespace, _('Maximum number of value streams per namespace exceeded'))
+ end
+
def ensure_aggregation_record_presence
Analytics::CycleAnalytics::Aggregation.safe_create_for_namespace(namespace)
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index a71b47e88d8..827f8bc93be 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -38,7 +38,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
encrypted_tofa_url
encrypted_tofa_url_iv
vertex_project
- ], remove_with: '16.2', remove_after: '2023-06-22'
+ ], remove_with: '16.3', remove_after: '2023-07-22'
INSTANCE_REVIEW_MIN_USERS = 50
GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \
@@ -596,6 +596,13 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
allow_blank: true,
public_url: ADDRESSABLE_URL_VALIDATION_OPTIONS
+ with_options(presence: true, if: :slack_app_enabled?) do
+ validates :slack_app_id
+ validates :slack_app_secret
+ validates :slack_app_signing_secret
+ validates :slack_app_verification_token
+ end
+
with_options(presence: true, numericality: { only_integer: true, greater_than: 0 }) do
validates :throttle_unauthenticated_api_requests_per_period
validates :throttle_unauthenticated_api_period_in_seconds
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index 9370982be47..163e741d990 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -100,40 +100,6 @@ class AuditEvent < ApplicationRecord
super || details[:target_details]
end
- def self.by_group(group)
- group_id = group.id
-
- # Bring entity_type and entity_id from projects and group into one query
- scope1 = Group.find(group_id).all_projects.select("'Project' as entity_type", 'id AS entity_id')
- scope2 = Project.from("(VALUES ('Group', #{group_id})) as projects(entity_type, entity_id)").select('entity_type',
- 'entity_id')
- array_scope = Project.from_union([scope1, scope2], remove_duplicates: false).select(:entity_type, :entity_id)
-
- # order by created_at (id is the tie breaker)
- scope = AuditEvent.order(:created_at, :id)
-
- array_mapping_scope = ->(entity_type_expression, entity_id_expression) do
- AuditEvent.where(AuditEvent.arel_table[:entity_id].eq(entity_id_expression))
- .where(AuditEvent.arel_table[:entity_type].eq(entity_type_expression))
- end
-
- finder_query = ->(created_at_expression, id_expression) do
- # we need to add created_at filter as well because that's the partitioning key
- AuditEvent.where(
- AuditEvent.arel_table[:id].eq(id_expression)
- ).where(
- AuditEvent.arel_table[:created_at].eq(created_at_expression)
- )
- end
-
- Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder.new(
- scope: scope,
- array_scope: array_scope,
- array_mapping_scope: array_mapping_scope,
- finder_query: finder_query
- ).execute
- end
-
private
def sanitize_message
diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb
index 31bee8db1b4..ebc43b04b1b 100644
--- a/app/models/award_emoji.rb
+++ b/app/models/award_emoji.rb
@@ -31,6 +31,7 @@ class AwardEmoji < ApplicationRecord
after_destroy :expire_cache
after_save :expire_cache
+ after_commit :broadcast_note_update, if: -> { !importing? && awardable.is_a?(Note) }
class << self
def votes_for_collection(ids, type)
@@ -73,11 +74,19 @@ class AwardEmoji < ApplicationRecord
def expire_cache
awardable.try(:bump_updated_at)
- awardable.expire_etag_cache if awardable.is_a?(Note)
awardable.try(:update_upvotes_count) if upvote?
end
+ def broadcast_note_update
+ awardable.expire_etag_cache
+ awardable.trigger_note_subscription_update
+ end
+
def to_ability_name
'emoji'
end
+
+ def hook_attrs
+ Gitlab::HookData::EmojiBuilder.new(self).build
+ end
end
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index bf25ea7367c..ccc5ca7395d 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -3,7 +3,6 @@
class BroadcastMessage < MainClusterwide::ApplicationRecord
include CacheMarkdownField
include Sortable
- include IgnorableColumns
ALLOWED_TARGET_ACCESS_LEVELS = [
Gitlab::Access::GUEST,
@@ -13,8 +12,6 @@ class BroadcastMessage < MainClusterwide::ApplicationRecord
Gitlab::Access::OWNER
].freeze
- ignore_column :namespace_id, remove_with: '16.0', remove_after: '2022-06-22'
-
cache_markdown_field :message, pipeline: :broadcast_message, whitelisted: true
validates :message, presence: true
diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb
index c2d7529f468..fde528e3fa0 100644
--- a/app/models/bulk_import.rb
+++ b/app/models/bulk_import.rb
@@ -58,6 +58,10 @@ class BulkImport < ApplicationRecord
Gitlab::VersionInfo.new(MIN_MAJOR_VERSION, MIN_MINOR_VERSION_FOR_PROJECT)
end
+ def self.min_gl_version_for_migration_in_batches
+ Gitlab::VersionInfo.new(16, 2)
+ end
+
def self.all_human_statuses
state_machine.states.map(&:human_name)
end
@@ -68,4 +72,8 @@ class BulkImport < ApplicationRecord
update!(has_failures: true)
end
+
+ def supports_batched_export?
+ source_version_info >= self.class.min_gl_version_for_migration_in_batches
+ end
end
diff --git a/app/models/bulk_imports/batch_tracker.rb b/app/models/bulk_imports/batch_tracker.rb
index df1fab89ee6..2e79d41d46e 100644
--- a/app/models/bulk_imports/batch_tracker.rb
+++ b/app/models/bulk_imports/batch_tracker.rb
@@ -25,9 +25,7 @@ module BulkImports
end
event :finish do
- transition started: :finished
- transition failed: :failed
- transition skipped: :skipped
+ transition any => :finished
end
event :skip do
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index 94e4a8165eb..4f50a112141 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -144,12 +144,27 @@ class BulkImports::Entity < ApplicationRecord
end
end
- def export_relations_url_path
- "#{base_resource_path}/export_relations"
+ def export_relations_url_path_base
+ File.join(base_resource_path, 'export_relations')
end
- def relation_download_url_path(relation)
- "#{export_relations_url_path}/download?relation=#{relation}"
+ def export_relations_url_path(batched: false)
+ if batched && bulk_import.supports_batched_export?
+ Gitlab::Utils.add_url_parameters(export_relations_url_path_base, batched: batched)
+ else
+ export_relations_url_path_base
+ end
+ end
+
+ def relation_download_url_path(relation, batch_number = nil)
+ url = File.join(export_relations_url_path_base, 'download')
+ params = { relation: relation }
+
+ if batch_number && bulk_import.supports_batched_export?
+ params.merge!(batched: true, batch_number: batch_number)
+ end
+
+ Gitlab::Utils.add_url_parameters(url, params)
end
def wikis_url_path
diff --git a/app/models/bulk_imports/export.rb b/app/models/bulk_imports/export.rb
index 93cf047c690..5c3f8e4b8d4 100644
--- a/app/models/bulk_imports/export.rb
+++ b/app/models/bulk_imports/export.rb
@@ -32,9 +32,7 @@ module BulkImports
end
event :finish do
- transition started: :finished
- transition finished: :finished
- transition failed: :failed
+ transition any => :finished
end
event :fail_op do
@@ -63,5 +61,12 @@ module BulkImports
FileTransfer.config_for(portable)
end
end
+
+ def remove_existing_upload!
+ return unless upload&.export_file&.file
+
+ upload.remove_export_file!
+ upload.save!
+ end
end
end
diff --git a/app/models/bulk_imports/export_status.rb b/app/models/bulk_imports/export_status.rb
index cbd7b189007..3d820e65d5b 100644
--- a/app/models/bulk_imports/export_status.rb
+++ b/app/models/bulk_imports/export_status.rb
@@ -13,28 +13,48 @@ module BulkImports
end
def started?
- !empty? && export_status['status'] == Export::STARTED
+ !empty? && status['status'] == Export::STARTED
end
def failed?
- !empty? && export_status['status'] == Export::FAILED
+ !empty? && status['status'] == Export::FAILED
end
def empty?
- export_status.nil?
+ status.nil?
end
def error
- export_status['error']
+ status['error']
+ end
+
+ def batched?
+ status['batched'] == true
+ end
+
+ def batches_count
+ status['batches_count'].to_i
+ end
+
+ def batch(batch_number)
+ raise ArgumentError if batch_number < 1
+
+ return unless batched?
+
+ status['batches'].find { |item| item['batch_number'] == batch_number }
end
private
attr_reader :client, :entity, :relation, :pipeline_tracker
- def export_status
- strong_memoize(:export_status) do
- fetch_export_status&.find { |item| item['relation'] == relation }
+ def status
+ strong_memoize(:status) do
+ status = fetch_status
+
+ next status if status.is_a?(Hash) || status.nil?
+
+ status.find { |item| item['relation'] == relation }
rescue BulkImports::NetworkError => e
raise BulkImports::RetryPipelineError.new(e.message, 2.seconds) if e.retriable?(pipeline_tracker)
@@ -44,12 +64,12 @@ module BulkImports
end
end
- def fetch_export_status
- client.get(status_endpoint).parsed_response
+ def fetch_status
+ client.get(status_endpoint, relation: relation).parsed_response
end
def status_endpoint
- File.join(entity.export_relations_url_path, 'status')
+ File.join(entity.export_relations_url_path_base, 'status')
end
def default_error_response(message)
diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb
index 55502721a76..d1a6f3b9a80 100644
--- a/app/models/bulk_imports/tracker.rb
+++ b/app/models/bulk_imports/tracker.rb
@@ -24,6 +24,7 @@ class BulkImports::Tracker < ApplicationRecord
delegate :file_extraction_pipeline?, to: :pipeline_class
DEFAULT_PAGE_SIZE = 500
+ STALE_AFTER = 4.hours
scope :next_pipeline_trackers_for, -> (entity_id) {
entity_scope = where(bulk_import_entity_id: entity_id)
@@ -89,4 +90,8 @@ class BulkImports::Tracker < ApplicationRecord
transition [:created, :started] => :timeout
end
end
+
+ def stale?
+ created_at < STALE_AFTER.ago
+ end
end
diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb
index f87b18d516f..1f6d218b015 100644
--- a/app/models/ci/artifact_blob.rb
+++ b/app/models/ci/artifact_blob.rb
@@ -4,8 +4,6 @@ module Ci
class ArtifactBlob
include BlobLike
- EXTENSIONS_SERVED_BY_PAGES = %w[.html .htm .txt .json .xml .log].freeze
-
attr_reader :entry
def initialize(entry)
@@ -35,31 +33,18 @@ module Ci
:build_artifact
end
- def external_url(project, job)
- return unless external_link?(job)
-
- url_project_path = project.full_path.partition('/').last
-
- artifact_path = [
- '-', url_project_path, '-',
- 'jobs', job.id,
- 'artifacts', path
- ].join('/')
-
- "#{project.pages_namespace_url}/#{artifact_path}"
+ def external_url(job)
+ pages_url_builder(job.project).artifact_url(entry, job)
end
def external_link?(job)
- pages_config.enabled &&
- pages_config.artifacts_server &&
- EXTENSIONS_SERVED_BY_PAGES.include?(File.extname(name)) &&
- (pages_config.access_control || job.project.public?)
+ pages_url_builder(job.project).artifact_url_available?(entry, job)
end
private
- def pages_config
- Gitlab.config.pages
+ def pages_url_builder(project)
+ @pages_url_builder ||= Gitlab::Pages::UrlBuilder.new(project)
end
end
end
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 7cdd0d56a98..5052d84378f 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -224,15 +224,46 @@ module Ci
end
end
+ def target_revision_ref
+ downstream_pipeline_params.dig(:target_revision, :ref)
+ end
+
def downstream_variables
- calculate_downstream_variables
- .reverse # variables priority
- .uniq { |var| var[:key] } # only one variable key to pass
- .reverse
+ Gitlab::Ci::Variables::Downstream::Generator.new(self).calculate
end
- def target_revision_ref
- downstream_pipeline_params.dig(:target_revision, :ref)
+ def variables
+ strong_memoize(:variables) do
+ Gitlab::Ci::Variables::Collection.new
+ .concat(scoped_variables)
+ .concat(pipeline.persisted_variables)
+ end
+ end
+
+ def pipeline_variables
+ pipeline.variables
+ end
+
+ def pipeline_schedule_variables
+ return [] unless pipeline.pipeline_schedule
+
+ pipeline.pipeline_schedule.variables.to_a
+ end
+
+ def forward_yaml_variables?
+ strong_memoize(:forward_yaml_variables) do
+ result = options&.dig(:trigger, :forward, :yaml_variables)
+
+ result.nil? ? FORWARD_DEFAULTS[:yaml_variables] : result
+ end
+ end
+
+ def forward_pipeline_variables?
+ strong_memoize(:forward_pipeline_variables) do
+ result = options&.dig(:trigger, :forward, :pipeline_variables)
+
+ result.nil? ? FORWARD_DEFAULTS[:pipeline_variables] : result
+ end
end
private
@@ -273,70 +304,6 @@ module Ci
}
}
end
-
- def calculate_downstream_variables
- expand_variables = scoped_variables
- .concat(pipeline.persisted_variables)
- .to_runner_variables
-
- # The order of this list refers to the priority of the variables
- downstream_yaml_variables(expand_variables) +
- downstream_pipeline_variables(expand_variables) +
- downstream_pipeline_schedule_variables(expand_variables)
- end
-
- def downstream_yaml_variables(expand_variables)
- return [] unless forward_yaml_variables?
-
- yaml_variables.to_a.map do |hash|
- if hash[:raw]
- { key: hash[:key], value: hash[:value], raw: true }
- else
- { key: hash[:key], value: ::ExpandVariables.expand(hash[:value], expand_variables) }
- end
- end
- end
-
- def downstream_pipeline_variables(expand_variables)
- return [] unless forward_pipeline_variables?
-
- pipeline.variables.to_a.map do |variable|
- if variable.raw?
- { key: variable.key, value: variable.value, raw: true }
- else
- { key: variable.key, value: ::ExpandVariables.expand(variable.value, expand_variables) }
- end
- end
- end
-
- def downstream_pipeline_schedule_variables(expand_variables)
- return [] unless forward_pipeline_variables?
- return [] unless pipeline.pipeline_schedule
-
- pipeline.pipeline_schedule.variables.to_a.map do |variable|
- if variable.raw?
- { key: variable.key, value: variable.value, raw: true }
- else
- { key: variable.key, value: ::ExpandVariables.expand(variable.value, expand_variables) }
- end
- end
- end
-
- def forward_yaml_variables?
- strong_memoize(:forward_yaml_variables) do
- result = options&.dig(:trigger, :forward, :yaml_variables)
-
- result.nil? ? FORWARD_DEFAULTS[:yaml_variables] : result
- end
- end
-
- def forward_pipeline_variables?
- strong_memoize(:forward_pipeline_variables) do
- result = options&.dig(:trigger, :forward, :pipeline_variables)
-
- result.nil? ? FORWARD_DEFAULTS[:pipeline_variables] : result
- end
- end
end
end
diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb
index 382f861a802..4c723bb7c0c 100644
--- a/app/models/ci/build_metadata.rb
+++ b/app/models/ci/build_metadata.rb
@@ -10,11 +10,9 @@ module Ci
include Presentable
include ChronicDurationAttribute
include Gitlab::Utils::StrongMemoize
- include SafelyChangeColumnDefault
self.table_name = 'p_ci_builds_metadata'
self.primary_key = 'id'
- columns_changing_default :partition_id
partitionable scope: :build
diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb
index 940221619b3..317f2523f69 100644
--- a/app/models/ci/build_need.rb
+++ b/app/models/ci/build_need.rb
@@ -3,8 +3,12 @@
module Ci
class BuildNeed < Ci::ApplicationRecord
include Ci::Partitionable
- include BulkInsertSafe
include IgnorableColumns
+ include SafelyChangeColumnDefault
+ include BulkInsertSafe
+
+ columns_changing_default :partition_id
+ ignore_column :id_convert_to_bigint, remove_with: '16.4', remove_after: '2023-09-22'
belongs_to :build, class_name: "Ci::Processable", foreign_key: :build_id, inverse_of: :needs
diff --git a/app/models/ci/build_pending_state.rb b/app/models/ci/build_pending_state.rb
index 966884ae158..0b88f745d78 100644
--- a/app/models/ci/build_pending_state.rb
+++ b/app/models/ci/build_pending_state.rb
@@ -2,6 +2,9 @@
class Ci::BuildPendingState < Ci::ApplicationRecord
include Ci::Partitionable
+ include SafelyChangeColumnDefault
+
+ columns_changing_default :partition_id
belongs_to :build, class_name: 'Ci::Build', foreign_key: :build_id, inverse_of: :pending_state
diff --git a/app/models/ci/build_report_result.rb b/app/models/ci/build_report_result.rb
index b2d99fab295..90b621b8da1 100644
--- a/app/models/ci/build_report_result.rb
+++ b/app/models/ci/build_report_result.rb
@@ -3,6 +3,9 @@
module Ci
class BuildReportResult < Ci::ApplicationRecord
include Ci::Partitionable
+ include SafelyChangeColumnDefault
+
+ columns_changing_default :partition_id
self.primary_key = :build_id
diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb
index 5773b6132be..eaa2e1c428e 100644
--- a/app/models/ci/build_runner_session.rb
+++ b/app/models/ci/build_runner_session.rb
@@ -5,6 +5,9 @@ module Ci
# Data will be removed after transitioning from running to any state.
class BuildRunnerSession < Ci::ApplicationRecord
include Ci::Partitionable
+ include SafelyChangeColumnDefault
+
+ columns_changing_default :partition_id
TERMINAL_SUBPROTOCOL = 'terminal.gitlab.com'
DEFAULT_SERVICE_NAME = 'build'
diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb
index 03b59b19ef1..0a0f401c9d5 100644
--- a/app/models/ci/build_trace_chunk.rb
+++ b/app/models/ci/build_trace_chunk.rb
@@ -8,6 +8,9 @@ module Ci
include ::Checksummable
include ::Gitlab::ExclusiveLeaseHelpers
include ::Gitlab::OptimisticLocking
+ include SafelyChangeColumnDefault
+
+ columns_changing_default :partition_id
belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id, inverse_of: :trace_chunks
@@ -166,7 +169,7 @@ module Ci
raise FailedToPersistDataError, 'Modifed build trace chunk detected' if has_changes_to_save?
self.class.with_read_consistency(build) do
- reset.then(&:unsafe_persist_data!)
+ reset.unsafe_persist_data!
end
end
rescue FailedToObtainLockError
@@ -242,7 +245,7 @@ module Ci
##
# We need to so persist data then save a new store identifier before we
# remove data from the previous store to make this operation
- # trasnaction-safe. `unsafe_set_data! calls `save!` because of this
+ # transaction-safe. `unsafe_set_data! calls `save!` because of this
# reason.
#
# TODO consider using callbacks and state machine to remove old data
diff --git a/app/models/ci/build_trace_metadata.rb b/app/models/ci/build_trace_metadata.rb
index 4c76089617f..c5ad3d19425 100644
--- a/app/models/ci/build_trace_metadata.rb
+++ b/app/models/ci/build_trace_metadata.rb
@@ -3,6 +3,9 @@
module Ci
class BuildTraceMetadata < Ci::ApplicationRecord
include Ci::Partitionable
+ include SafelyChangeColumnDefault
+
+ columns_changing_default :partition_id
MAX_ATTEMPTS = 5
self.table_name = 'ci_build_trace_metadata'
diff --git a/app/models/ci/catalog/resource.rb b/app/models/ci/catalog/resource.rb
index 77cfe91ddd6..38603ddfe59 100644
--- a/app/models/ci/catalog/resource.rb
+++ b/app/models/ci/catalog/resource.rb
@@ -19,6 +19,8 @@ module Ci
delegate :avatar_path, :description, :name, :star_count, :forks_count, to: :project
+ enum state: { draft: 0, published: 1 }
+
def versions
project.releases.order_released_desc
end
diff --git a/app/models/ci/external_pull_request.rb b/app/models/ci/external_pull_request.rb
new file mode 100644
index 00000000000..bd37aa9f85a
--- /dev/null
+++ b/app/models/ci/external_pull_request.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+# This model stores pull requests coming from external providers, such as
+# GitHub, when GitLab project is set as CI/CD only and remote mirror.
+#
+# When setting up a remote mirror with GitHub we subscribe to push and
+# pull_request webhook events. When a pull request is opened on GitHub,
+# a webhook is sent out, we create or update the status of the pull
+# request locally.
+#
+# When the mirror is updated and changes are pushed to branches we check
+# if there are open pull requests for the source and target branch.
+# If so, we create pipelines for external pull requests.
+module Ci
+ class ExternalPullRequest < Ci::ApplicationRecord
+ include Gitlab::Utils::StrongMemoize
+ include ShaAttribute
+ include EachBatch
+
+ belongs_to :project
+
+ sha_attribute :source_sha
+ sha_attribute :target_sha
+
+ validates :source_branch, presence: true
+ validates :target_branch, presence: true
+ validates :source_sha, presence: true
+ validates :target_sha, presence: true
+ validates :source_repository, presence: true
+ validates :target_repository, presence: true
+ validates :status, presence: true
+
+ enum status: {
+ open: 1,
+ closed: 2
+ }
+
+ # We currently don't support pull requests from fork, so
+ # we are going to return an error to the webhook
+ validate :not_from_fork
+
+ scope :by_source_branch, ->(branch) { where(source_branch: branch) }
+ scope :by_source_repository, ->(repository) { where(source_repository: repository) }
+
+ # Needed to override Ci::ApplicationRecord as this does not have ci_ table prefix
+ self.table_name = 'external_pull_requests'
+
+ def self.create_or_update_from_params(params)
+ find_params = params.slice(:project_id, :source_branch, :target_branch)
+
+ safe_find_or_initialize_and_update(find: find_params, update: params) do |pull_request|
+ yield(pull_request) if block_given?
+ end
+ end
+
+ def actual_branch_head?
+ actual_source_branch_sha == source_sha
+ end
+
+ def from_fork?
+ source_repository != target_repository
+ end
+
+ def source_ref
+ Gitlab::Git::BRANCH_REF_PREFIX + source_branch
+ end
+
+ def predefined_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_IID', value: pull_request_iid.to_s)
+ variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_REPOSITORY', value: source_repository)
+ variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_TARGET_REPOSITORY', value: target_repository)
+ variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_SHA', value: source_sha)
+ variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_SHA', value: target_sha)
+ variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_NAME', value: source_branch)
+ variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_NAME', value: target_branch)
+ end
+ end
+
+ def modified_paths
+ project.repository.diff_stats(target_sha, source_sha).paths
+ end
+
+ private
+
+ def actual_source_branch_sha
+ project.commit(source_ref)&.sha
+ end
+
+ def not_from_fork
+ return unless from_fork?
+
+ errors.add(:base, _('Pull requests from fork are not supported'))
+ end
+
+ def self.safe_find_or_initialize_and_update(find:, update:)
+ safe_ensure_unique(retries: 1) do
+ model = find_or_initialize_by(find)
+
+ yield(model) if model.update(update) && block_given?
+
+ model
+ end
+ end
+ end
+end
diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb
index 5522a01758f..25d0228beb0 100644
--- a/app/models/ci/group_variable.rb
+++ b/app/models/ci/group_variable.rb
@@ -14,6 +14,7 @@ module Ci
alias_attribute :secret_value, :value
+ validates :description, length: { maximum: 255 }, allow_blank: true
validates :key, uniqueness: {
scope: [:group_id, :environment_scope],
message: "(%{value}) has already been taken"
@@ -36,6 +37,12 @@ module Ci
.pluck(:environment_scope)
end
+ # Sorting
+ scope :order_created_asc, -> { reorder(created_at: :asc) }
+ scope :order_created_desc, -> { reorder(created_at: :desc) }
+ scope :order_key_asc, -> { reorder(key: :asc) }
+ scope :order_key_desc, -> { reorder(key: :desc) }
+
self.limit_name = 'group_ci_variables'
self.limit_scope = :group
@@ -50,5 +57,14 @@ module Ci
def group_ci_cd_settings_path
Gitlab::Routing.url_helpers.group_settings_ci_cd_path(group)
end
+
+ def self.sort_by_attribute(method)
+ case method.to_s
+ when 'created_at_asc' then order_created_asc
+ when 'created_at_desc' then order_created_desc
+ when 'key_asc' then order_key_asc
+ when 'key_desc' then order_key_desc
+ end
+ end
end
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 5cd7988837e..11d70e088e9 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -13,6 +13,9 @@ module Ci
include FileStoreMounter
include EachBatch
include Gitlab::Utils::StrongMemoize
+ include SafelyChangeColumnDefault
+
+ columns_changing_default :partition_id
enum accessibility: { public: 0, private: 1 }, _suffix: true
diff --git a/app/models/ci/job_variable.rb b/app/models/ci/job_variable.rb
index 573999995bc..21c9842399e 100644
--- a/app/models/ci/job_variable.rb
+++ b/app/models/ci/job_variable.rb
@@ -5,8 +5,11 @@ module Ci
include Ci::Partitionable
include Ci::NewHasVariable
include Ci::RawVariable
+ include SafelyChangeColumnDefault
include BulkInsertSafe
+ columns_changing_default :partition_id
+
belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id, inverse_of: :job_variables
partitionable scope: :job
diff --git a/app/models/ci/pending_build.rb b/app/models/ci/pending_build.rb
index 14050a1e78e..dc9a8b7a1bf 100644
--- a/app/models/ci/pending_build.rb
+++ b/app/models/ci/pending_build.rb
@@ -4,6 +4,9 @@ module Ci
class PendingBuild < Ci::ApplicationRecord
include EachBatch
include Ci::Partitionable
+ include SafelyChangeColumnDefault
+
+ columns_changing_default :partition_id
belongs_to :project
belongs_to :build, class_name: 'Ci::Build'
diff --git a/app/models/ci/persistent_ref.rb b/app/models/ci/persistent_ref.rb
index 57aa1962bd2..f713d5952bc 100644
--- a/app/models/ci/persistent_ref.rb
+++ b/app/models/ci/persistent_ref.rb
@@ -19,6 +19,11 @@ module Ci
false
end
+ # This needs to be kept in sync with `Ci::Pipeline#after_transition` calling `pipeline.persistent_ref.delete`
+ def should_delete?
+ pipeline.status.to_sym.in?(::Ci::Pipeline.stopped_statuses)
+ end
+
def create
create_ref(sha, path)
rescue StandardError => e
@@ -27,6 +32,8 @@ module Ci
end
def delete
+ return unless should_delete?
+
delete_refs(path)
rescue Gitlab::Git::Repository::NoRepository
# no-op
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 6f2939583e0..bd327cfbe7b 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -17,6 +17,9 @@ module Ci
include UpdatedAtFilterable
include EachBatch
include FastDestroyAll::Helpers
+ include SafelyChangeColumnDefault
+
+ columns_changing_default :partition_id
include IgnorableColumns
ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22'
@@ -51,7 +54,7 @@ module Ci
belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline', inverse_of: :auto_canceled_pipelines
belongs_to :pipeline_schedule, class_name: 'Ci::PipelineSchedule'
belongs_to :merge_request, class_name: 'MergeRequest'
- belongs_to :external_pull_request
+ belongs_to :external_pull_request, class_name: 'Ci::ExternalPullRequest'
belongs_to :ci_ref, class_name: 'Ci::Ref', foreign_key: :ci_ref_id, inverse_of: :pipelines
has_internal_id :iid, scope: :project, presence: false,
@@ -335,9 +338,14 @@ module Ci
end
end
+ # This needs to be kept in sync with `Ci::PipelineRef#should_delete?`
after_transition any => ::Ci::Pipeline.stopped_statuses do |pipeline|
pipeline.run_after_commit do
- pipeline.persistent_ref.delete
+ if Feature.enabled?(:pipeline_cleanup_ref_worker_async, pipeline.project)
+ ::Ci::PipelineCleanupRefWorker.perform_async(pipeline.id)
+ else
+ pipeline.persistent_ref.delete
+ end
end
end
diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb
index f2457af0074..9747f9ef527 100644
--- a/app/models/ci/pipeline_variable.rb
+++ b/app/models/ci/pipeline_variable.rb
@@ -5,9 +5,12 @@ module Ci
include Ci::Partitionable
include Ci::HasVariable
include Ci::RawVariable
-
include IgnorableColumns
+ include SafelyChangeColumnDefault
+
+ columns_changing_default :partition_id
ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22'
+ ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.5', remove_after: '2023-10-22'
belongs_to :pipeline
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 6319163b0d7..4eb5c3c9ed2 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -72,6 +72,7 @@ module Ci
has_many :runner_managers, inverse_of: :runner
has_many :builds
+ has_many :running_builds, inverse_of: :runner
has_many :runner_projects, inverse_of: :runner, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, through: :runner_projects, disable_joins: true
has_many :runner_namespaces, inverse_of: :runner, autosave: true
@@ -198,6 +199,7 @@ module Ci
scope :order_created_at_desc, -> { order(created_at: :desc) }
scope :order_token_expires_at_asc, -> { order(token_expires_at: :asc) }
scope :order_token_expires_at_desc, -> { order(token_expires_at: :desc) }
+
scope :with_tags, -> { preload(:tags) }
scope :with_creator, -> { preload(:creator) }
@@ -456,7 +458,7 @@ module Ci
end
new_version = values[:version]
- schedule_runner_version_update(new_version) if new_version && values[:version] != version
+ schedule_runner_version_update(new_version) if new_version && new_version != version
merge_cache_attributes(values)
diff --git a/app/models/ci/runner_manager.rb b/app/models/ci/runner_manager.rb
index e36024d9f5b..3a3f95a8c69 100644
--- a/app/models/ci/runner_manager.rb
+++ b/app/models/ci/runner_manager.rb
@@ -44,6 +44,10 @@ module Ci
remove_duplicates: false).where(created_some_time_ago)
end
+ scope :for_runner, ->(runner_id) do
+ where(runner_id: runner_id)
+ end
+
def self.online_contact_time_deadline
Ci::Runner.online_contact_time_deadline
end
@@ -52,6 +56,13 @@ module Ci
STALE_TIMEOUT.ago
end
+ def self.aggregate_upgrade_status_by_runner_id
+ joins(:runner_version)
+ .group(:runner_id)
+ .maximum(:status)
+ .transform_values { |s| Ci::RunnerVersion.statuses.key(s).to_sym }
+ end
+
def heartbeat(values, update_contacted_at: true)
##
# We can safely ignore writes performed by a runner heartbeat. We do
@@ -66,7 +77,7 @@ module Ci
end
new_version = values[:version]
- schedule_runner_version_update(new_version) if new_version && values[:version] != version
+ schedule_runner_version_update(new_version) if new_version && new_version != version
merge_cache_attributes(values)
diff --git a/app/models/ci/running_build.rb b/app/models/ci/running_build.rb
index e6f80658f5d..cfdc47de531 100644
--- a/app/models/ci/running_build.rb
+++ b/app/models/ci/running_build.rb
@@ -10,6 +10,9 @@ module Ci
# of the running builds there is worth the additional pressure.
class RunningBuild < Ci::ApplicationRecord
include Ci::Partitionable
+ include SafelyChangeColumnDefault
+
+ columns_changing_default :partition_id
partitionable scope: :build
diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb
index 719d19f4169..4853c57d41f 100644
--- a/app/models/ci/sources/pipeline.rb
+++ b/app/models/ci/sources/pipeline.rb
@@ -5,6 +5,9 @@ module Ci
class Pipeline < Ci::ApplicationRecord
include Ci::Partitionable
include Ci::NamespacedModelName
+ include SafelyChangeColumnDefault
+
+ columns_changing_default :partition_id
self.table_name = "ci_sources_pipelines"
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index d61760bd0fc..4f9a2e44562 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -7,6 +7,9 @@ module Ci
include Ci::HasStatus
include Gitlab::OptimisticLocking
include Presentable
+ include SafelyChangeColumnDefault
+
+ columns_changing_default :partition_id
partitionable scope: :pipeline
@@ -148,7 +151,7 @@ module Ci
end
def manual_playable?
- blocked? || skipped?
+ blocked?
end
# This will be removed with ci_remove_ensure_stage_service
diff --git a/app/models/ci/unit_test_failure.rb b/app/models/ci/unit_test_failure.rb
index cfef1249164..37893f6cdae 100644
--- a/app/models/ci/unit_test_failure.rb
+++ b/app/models/ci/unit_test_failure.rb
@@ -3,6 +3,9 @@
module Ci
class UnitTestFailure < Ci::ApplicationRecord
include Ci::Partitionable
+ include SafelyChangeColumnDefault
+
+ columns_changing_default :partition_id
REPORT_WINDOW = 14.days
diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb
index 23fe89c38df..6f5972ebefa 100644
--- a/app/models/ci/variable.rb
+++ b/app/models/ci/variable.rb
@@ -14,6 +14,7 @@ module Ci
alias_attribute :secret_value, :value
+ validates :description, length: { maximum: 255 }, allow_blank: true
validates :key, uniqueness: {
scope: [:project_id, :environment_scope],
message: "(%{value}) has already been taken"
diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb
index 372fdfda1ea..8dc866929f3 100644
--- a/app/models/clusters/agent.rb
+++ b/app/models/clusters/agent.rb
@@ -66,7 +66,6 @@ module Clusters
def ci_access_authorized_for?(user)
return false unless user
- return false unless ::Feature.enabled?(:expose_authorized_cluster_agents, project)
all_ci_access_authorized_projects_for(user).exists? ||
all_ci_access_authorized_namespaces_for(user).exists?
@@ -74,7 +73,6 @@ module Clusters
def user_access_authorized_for?(user)
return false unless user
- return false unless ::Feature.enabled?(:expose_authorized_cluster_agents, project)
Clusters::Agents::Authorizations::UserAccess::Finder
.new(user, agent: self, preload: false, limit: 1).execute.any?
diff --git a/app/models/clusters/concerns/prometheus_client.rb b/app/models/clusters/concerns/prometheus_client.rb
index 10cb307addd..d2f69b813aa 100644
--- a/app/models/clusters/concerns/prometheus_client.rb
+++ b/app/models/clusters/concerns/prometheus_client.rb
@@ -29,7 +29,7 @@ module Clusters
rescue Kubeclient::HttpError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::ENETUNREACH
# If users have mistakenly set parameters or removed the depended clusters,
# `proxy_url` could raise an exception because gitlab can not communicate with the cluster.
- # Since `PrometheusAdapter#can_query?` is eargely loaded on environement pages in gitlab,
+ # Since `PrometheusAdapter#can_query?` is eargely loaded on environment pages in gitlab,
# we need to silence the exceptions
end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 26412205899..ded4b06a028 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -149,6 +149,10 @@ class Commit
from_hash(hash, project)
end
+
+ def underscore
+ 'commit'
+ end
end
attr_accessor :raw
diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb
index 90cdd267cbd..c6e507e4b6c 100644
--- a/app/models/commit_range.rb
+++ b/app/models/commit_range.rb
@@ -64,7 +64,7 @@ class CommitRange
range_string = range_string.strip
- unless range_string =~ /\A#{PATTERN}\z/o
+ unless /\A#{PATTERN}\z/o.match?(range_string)
raise ArgumentError, "invalid CommitRange string format: #{range_string}"
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index f26831c1049..3f631f583b6 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -8,13 +8,11 @@ class CommitStatus < Ci::ApplicationRecord
include Presentable
include BulkInsertableAssociations
include TaggableQueries
- include SafelyChangeColumnDefault
self.table_name = 'ci_builds'
self.sequence_name = 'ci_builds_id_seq'
self.primary_key = :id
partitionable scope: :pipeline
- columns_changing_default :partition_id
belongs_to :user
belongs_to :project
@@ -290,7 +288,7 @@ class CommitStatus < Ci::ApplicationRecord
def sortable_name
name.to_s.split(/(\d+)/).map do |v|
- v =~ /\d+/ ? v.to_i : v
+ /\d+/.match?(v) ? v.to_i : v
end
end
diff --git a/app/models/concerns/commit_signature.rb b/app/models/concerns/commit_signature.rb
index 5dac3c7833a..5bdf6bb31bf 100644
--- a/app/models/concerns/commit_signature.rb
+++ b/app/models/concerns/commit_signature.rb
@@ -16,7 +16,8 @@ module CommitSignature
unverified_key: 4,
unknown_key: 5,
multiple_signatures: 6,
- revoked_key: 7
+ revoked_key: 7,
+ verified_system: 8
}
belongs_to :project, class_name: 'Project', foreign_key: 'project_id', optional: false
diff --git a/app/models/concerns/database_event_tracking.rb b/app/models/concerns/database_event_tracking.rb
index 26e184c202f..7e2f445189e 100644
--- a/app/models/concerns/database_event_tracking.rb
+++ b/app/models/concerns/database_event_tracking.rb
@@ -3,8 +3,6 @@
module DatabaseEventTracking
extend ActiveSupport::Concern
- FEATURE_FLAG_BATCH2_CLASSES = %w[Vulnerability MergeRequest::Metrics].freeze
-
included do
after_create_commit :publish_database_create_event
after_destroy_commit :publish_database_destroy_event
@@ -24,9 +22,6 @@ module DatabaseEventTracking
end
def publish_database_event(name)
- return unless database_events_for_class_enabled?
- return unless database_events_feature_flag_enabled?
-
# Gitlab::Tracking#event is triggering Snowplow event
# Snowplow events are sent with usage of
# https://snowplow.github.io/snowplow-ruby-tracker/SnowplowTracker/AsyncEmitter.html
@@ -54,14 +49,4 @@ module DatabaseEventTracking
.with_indifferent_access
.slice(*self.class::SNOWPLOW_ATTRIBUTES)
end
-
- def database_events_for_class_enabled?
- is_batch2 = FEATURE_FLAG_BATCH2_CLASSES.include?(self.class.to_s)
-
- !is_batch2 || Feature.enabled?(:product_intelligence_database_event_tracking_batch2)
- end
-
- def database_events_feature_flag_enabled?
- Feature.enabled?(:product_intelligence_database_event_tracking)
- end
end
diff --git a/app/models/concerns/enums/ci/pipeline.rb b/app/models/concerns/enums/ci/pipeline.rb
index d798a13741f..f5ffeb8c425 100644
--- a/app/models/concerns/enums/ci/pipeline.rb
+++ b/app/models/concerns/enums/ci/pipeline.rb
@@ -85,7 +85,8 @@ module Enums
external_project_source: 5,
bridge_source: 6,
parameter_source: 7,
- compliance_source: 8
+ compliance_source: 8,
+ security_policies_default_source: 9
}
end
end
diff --git a/app/models/concerns/enums/vulnerability.rb b/app/models/concerns/enums/vulnerability.rb
index 4b325de61bc..dbf05dbc428 100644
--- a/app/models/concerns/enums/vulnerability.rb
+++ b/app/models/concerns/enums/vulnerability.rb
@@ -50,6 +50,10 @@ module Enums
CONFIDENCE_LEVELS
end
+ def self.parse_confidence_level(input)
+ input&.downcase.then { |value| confidence_levels.key?(value) ? value : 'unknown' }
+ end
+
def self.report_types
REPORT_TYPES
end
@@ -58,6 +62,10 @@ module Enums
SEVERITY_LEVELS
end
+ def self.parse_severity_level(input)
+ input&.downcase.then { |value| severity_levels.key?(value) ? value : 'unknown' }
+ end
+
def self.detection_methods
DETECTION_METHODS
end
diff --git a/app/models/concerns/expirable.rb b/app/models/concerns/expirable.rb
index cc55315d6d7..af139e735af 100644
--- a/app/models/concerns/expirable.rb
+++ b/app/models/concerns/expirable.rb
@@ -6,10 +6,8 @@ module Expirable
DAYS_TO_EXPIRE = 7
included do
- scope :not, ->(scope) { where(scope.arel.constraints.reduce(:and).not) }
-
- scope :expired, -> { where.not(expires_at: nil).where(arel_table[:expires_at].lteq(Time.current)) }
- scope :not_expired, -> { self.not(expired) }
+ scope :expired, -> { where(arel_table[:expires_at].lteq(Time.current)) }
+ scope :not_expired, -> { where(arel_table[:expires_at].gt(Time.current)).or(where(expires_at: nil)) }
end
def expired?
diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb
index 9d4b8328e8d..2d0ff82e624 100644
--- a/app/models/concerns/has_user_type.rb
+++ b/app/models/concerns/has_user_type.rb
@@ -14,7 +14,7 @@ module HasUserType
migration_bot: 7,
security_bot: 8,
automation_bot: 9,
- security_policy_bot: 10, # Currently not in use. See https://gitlab.com/gitlab-org/gitlab/-/issues/384174
+ security_policy_bot: 10,
admin_bot: 11,
suggested_reviewers_bot: 12,
service_account: 13,
diff --git a/app/models/concerns/ignorable_columns.rb b/app/models/concerns/ignorable_columns.rb
index 4cbcb25406d..249d0b99494 100644
--- a/app/models/concerns/ignorable_columns.rb
+++ b/app/models/concerns/ignorable_columns.rb
@@ -18,7 +18,7 @@ module IgnorableColumns
#
# Indicate the earliest date and release we can stop ignoring the column with +remove_after+ (a date string) and +remove_with+ (a release)
def ignore_columns(*columns, remove_after:, remove_with:)
- raise ArgumentError, 'Please indicate when we can stop ignoring columns with remove_after (date string YYYY-MM-DD), example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless remove_after =~ Gitlab::Regex.utc_date_regex
+ raise ArgumentError, 'Please indicate when we can stop ignoring columns with remove_after (date string YYYY-MM-DD), example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless Gitlab::Regex.utc_date_regex.match?(remove_after)
raise ArgumentError, 'Please indicate in which release we can stop ignoring columns with remove_with, example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless remove_with
self.ignored_columns += columns.flatten # rubocop:disable Cop/IgnoredColumns
diff --git a/app/models/concerns/issue_available_features.rb b/app/models/concerns/issue_available_features.rb
index 209456f8b67..3f65e701da7 100644
--- a/app/models/concerns/issue_available_features.rb
+++ b/app/models/concerns/issue_available_features.rb
@@ -19,7 +19,7 @@ module IssueAvailableFeatures
end
included do
- scope :with_feature, ->(feature) { where(issue_type: available_features_for_issue_types[feature]) }
+ scope :with_feature, ->(feature) { with_issue_type(available_features_for_issue_types[feature]) }
end
def issue_type_supports?(feature)
diff --git a/app/models/concerns/issues/forbid_issue_type_column_usage.rb b/app/models/concerns/issues/forbid_issue_type_column_usage.rb
deleted file mode 100644
index 46a8a0278d9..00000000000
--- a/app/models/concerns/issues/forbid_issue_type_column_usage.rb
+++ /dev/null
@@ -1,59 +0,0 @@
-# frozen_string_literal: true
-
-# TODO: Remove with https://gitlab.com/gitlab-org/gitlab/-/issues/402699
-module Issues
- module ForbidIssueTypeColumnUsage
- extend ActiveSupport::Concern
-
- ForbiddenColumnUsed = Class.new(StandardError)
-
- included do
- WorkItems::Type.base_types.each do |base_type, _value|
- define_method "#{base_type}?".to_sym do
- error_message = <<~ERROR
- `#{model_name.element}.#{base_type}?` uses the `issue_type` column underneath. As we want to remove the column,
- its usage is forbidden. You should use the `work_item_types` table instead.
-
- # Before
-
- #{model_name.element}.#{base_type}? => true
-
- # After
-
- #{model_name.element}.work_item_type.#{base_type}? => true
-
- More details in https://gitlab.com/groups/gitlab-org/-/epics/10529
- ERROR
-
- raise ForbiddenColumnUsed, error_message
- end
-
- define_singleton_method base_type.to_sym do
- error = ForbiddenColumnUsed.new(
- <<~ERROR
- `#{name}.#{base_type}` uses the `issue_type` column underneath. As we want to remove the column,
- its usage is forbidden. You should use the `work_item_types` table instead.
-
- # Before
-
- #{name}.#{base_type}
-
- # After
-
- #{name}.with_issue_type(:#{base_type})
-
- More details in https://gitlab.com/groups/gitlab-org/-/epics/10529
- ERROR
- )
-
- Gitlab::ErrorTracking.track_and_raise_for_dev_exception(
- error,
- method_name: "#{name}.#{base_type}"
- )
-
- with_issue_type(base_type.to_sym)
- end
- end
- end
- end
-end
diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb
index 4f2ea58f36d..3d9e09acf44 100644
--- a/app/models/concerns/milestoneish.rb
+++ b/app/models/concerns/milestoneish.rb
@@ -51,6 +51,7 @@ module Milestoneish
def issue_participants_visible_by_user(user)
User.joins(:issue_assignees)
.where('issue_assignees.issue_id' => issues_visible_to_user(user).select(:id))
+ .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417457")
.distinct
end
@@ -90,9 +91,9 @@ module Milestoneish
def expires_at
if due_date
if due_date.past?
- "expired on #{due_date.to_s(:medium)}"
+ "expired on #{due_date.to_fs(:medium)}"
else
- "expires on #{due_date.to_s(:medium)}"
+ "expires on #{due_date.to_fs(:medium)}"
end
end
end
diff --git a/app/models/concerns/packages/debian/component_file.rb b/app/models/concerns/packages/debian/component_file.rb
index cc7279d05f8..90d3abddbf1 100644
--- a/app/models/concerns/packages/debian/component_file.rb
+++ b/app/models/concerns/packages/debian/component_file.rb
@@ -10,8 +10,6 @@ module Packages
include FileStoreMounter
include IgnorableColumns
- ignore_column :file_md5, remove_with: '16.2', remove_after: '2023-06-22'
-
def self.container_foreign_key
"#{container_type}_id".to_sym
end
diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb
index 76c733b1c0b..c70100c03c8 100644
--- a/app/models/concerns/project_features_compatibility.rb
+++ b/app/models/concerns/project_features_compatibility.rb
@@ -4,7 +4,7 @@
#
# After migrating issues_enabled merge_requests_enabled builds_enabled snippets_enabled and wiki_enabled
# fields to a new table "project_features", support for the old fields is still needed in the API.
-require 'gitlab/utils'
+require 'gitlab/utils/all'
module ProjectFeaturesCompatibility
extend ActiveSupport::Concern
diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb
index 7e1ebd1eba3..a87eadb9332 100644
--- a/app/models/concerns/protected_ref.rb
+++ b/app/models/concerns/protected_ref.rb
@@ -32,7 +32,12 @@ module ProtectedRef
# to fail.
has_many :"#{type}_access_levels", inverse_of: self.model_name.singular
- validates :"#{type}_access_levels", length: { is: 1, message: "are restricted to a single instance per #{self.model_name.human}." }, unless: -> { allow_multiple?(type) }
+ validates :"#{type}_access_levels",
+ length: {
+ is: 1,
+ message: "are restricted to a single instance per #{self.model_name.human}."
+ },
+ unless: -> { allow_multiple?(type) }
accepts_nested_attributes_for :"#{type}_access_levels", allow_destroy: true
end
diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb
index c1c670db543..f0bb1cc359b 100644
--- a/app/models/concerns/protected_ref_access.rb
+++ b/app/models/concerns/protected_ref_access.rb
@@ -29,14 +29,30 @@ module ProtectedRefAccess
def humanize(access_level)
human_access_levels[access_level]
end
+
+ def non_role_types
+ []
+ end
end
included do
scope :maintainer, -> { where(access_level: Gitlab::Access::MAINTAINER) }
scope :developer, -> { where(access_level: Gitlab::Access::DEVELOPER) }
- scope :for_role, -> { where(user_id: nil, group_id: nil) }
-
- validates :access_level, presence: true, if: :role?, inclusion: { in: allowed_access_levels }
+ scope :for_role, -> {
+ if non_role_types.present?
+ where.missing(*non_role_types)
+ .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417457")
+ else
+ all
+ end
+ }
+
+ protected_ref_fk = "#{module_parent.model_name.singular}_id"
+ validates :access_level,
+ presence: true,
+ inclusion: { in: allowed_access_levels },
+ uniqueness: { scope: protected_ref_fk, conditions: -> { for_role } },
+ if: :role?
end
def humanize
diff --git a/app/models/concerns/protected_ref_deploy_key_access.rb b/app/models/concerns/protected_ref_deploy_key_access.rb
new file mode 100644
index 00000000000..4275476a1ff
--- /dev/null
+++ b/app/models/concerns/protected_ref_deploy_key_access.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+module ProtectedRefDeployKeyAccess
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :deploy_key
+
+ protected_ref_fk = "#{module_parent.model_name.singular}_id"
+ validates :deploy_key_id, uniqueness: { scope: protected_ref_fk, allow_nil: true }
+ validate :validate_deploy_key_membership
+ end
+
+ class_methods do
+ def non_role_types
+ super << :deploy_key
+ end
+ end
+
+ def type
+ return :deploy_key if deploy_key.present?
+
+ super
+ end
+
+ def humanize
+ return deploy_key.title if deploy_key?
+
+ super
+ end
+
+ def check_access(current_user)
+ super do
+ break enabled_deploy_key_for_user?(current_user) if deploy_key?
+
+ yield if block_given?
+ end
+ end
+
+ private
+
+ def deploy_key?
+ type == :deploy_key
+ end
+
+ def validate_deploy_key_membership
+ return if deploy_key.nil? || deploy_key_has_write_access_to_project?
+
+ errors.add(:deploy_key, 'is not enabled for this project')
+ end
+
+ def enabled_deploy_key_for_user?(current_user)
+ current_user.can?(:read_project, project) &&
+ deploy_key.user_id == current_user.id &&
+ deploy_key_has_write_access_to_project?
+ end
+
+ def deploy_key_has_write_access_to_project?
+ DeployKey.with_write_access_for_project(project, deploy_key: deploy_key).exists?
+ end
+end
diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb
index 6550c5a94a0..5986f8f5b5f 100644
--- a/app/models/concerns/spammable.rb
+++ b/app/models/concerns/spammable.rb
@@ -138,7 +138,7 @@ module Spammable
result.reject(&:blank?).join("\n")
end
- # Override in Spammable if further checks are necessary
+ # Override in included class if further checks are necessary
def check_for_spam?(*)
spammable_attribute_changed?
end
@@ -153,8 +153,8 @@ module Spammable
end
end
- # Override in Spammable if differs
- def allow_possible_spam?
+ # Override in included class if you want to allow possible spam under specific circumstances
+ def allow_possible_spam?(*)
Gitlab::CurrentSettings.allow_possible_spam
end
end
diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb
index e3800caa43f..0e72bd30a37 100644
--- a/app/models/concerns/triggerable_hooks.rb
+++ b/app/models/concerns/triggerable_hooks.rb
@@ -17,7 +17,8 @@ module TriggerableHooks
feature_flag_hooks: :feature_flag_events,
release_hooks: :releases_events,
member_hooks: :member_events,
- subgroup_hooks: :subgroup_events
+ subgroup_hooks: :subgroup_events,
+ emoji_hooks: :emoji_events
}.freeze
extend ActiveSupport::Concern
diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb
index a5b69997900..e8a50497b20 100644
--- a/app/models/concerns/vulnerability_finding_helpers.rb
+++ b/app/models/concerns/vulnerability_finding_helpers.rb
@@ -59,6 +59,7 @@ module VulnerabilityFindingHelpers
evidence = Vulnerabilities::Finding::Evidence.new(data: report_finding.evidence.data) if report_finding.evidence
Vulnerabilities::Finding.new(finding_data).tap do |finding|
+ finding.uuid = security_finding.uuid
finding.location_fingerprint = report_finding.location.fingerprint
finding.vulnerability = vulnerability_for(security_finding.uuid)
finding.project = project
diff --git a/app/models/concerns/vulnerability_finding_signature_helpers.rb b/app/models/concerns/vulnerability_finding_signature_helpers.rb
index 71a12b4077b..a225625815b 100644
--- a/app/models/concerns/vulnerability_finding_signature_helpers.rb
+++ b/app/models/concerns/vulnerability_finding_signature_helpers.rb
@@ -2,12 +2,17 @@
module VulnerabilityFindingSignatureHelpers
extend ActiveSupport::Concern
+
# If the location object describes a physical location within a file
# (filename + line numbers), the 'location' algorithm_type should be used
# If the location object describes arbitrary data, then the 'hash'
# algorithm_type should be used.
-
- ALGORITHM_TYPES = { hash: 1, location: 2, scope_offset: 3 }.with_indifferent_access.freeze
+ ALGORITHM_TYPES = {
+ hash: 1,
+ location: 2,
+ scope_offset: 3,
+ scope_offset_compressed: 4
+ }.with_indifferent_access.freeze
class_methods do
def priority(algorithm_type)
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index 0f0abeae795..6a52f6a0112 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -403,7 +403,7 @@ class ContainerRepository < ApplicationRecord
end
def migrated?
- Gitlab.com?
+ Gitlab.com_except_jh?
end
def last_import_step_done_at
@@ -526,7 +526,7 @@ class ContainerRepository < ApplicationRecord
def size
strong_memoize(:size) do
- next unless Gitlab.com?
+ next unless Gitlab.com_except_jh?
next if self.created_at.before?(MIGRATION_PHASE_1_STARTED_AT) && self.migration_state != 'import_done'
next unless gitlab_api_client.supports_gitlab_api?
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 1e3a80087c8..b59b22c10c4 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -351,7 +351,7 @@ class Deployment < ApplicationRecord
end
def formatted_deployment_time
- deployed_at&.to_time&.in_time_zone&.to_s(:medium)
+ deployed_at&.to_time&.in_time_zone&.to_fs(:medium)
end
def deployed_by
@@ -447,7 +447,7 @@ class Deployment < ApplicationRecord
# when refs_by_oid is passed an SHA, returns refs for that commit
def tags(limit: 100)
strong_memoize_with(:tag, limit) do
- project.repository.refs_by_oid(oid: sha, limit: limit, ref_patterns: [Gitlab::Git::TAG_REF_PREFIX]) || []
+ project.repository.refs_by_oid(oid: sha, limit: limit, ref_patterns: [Gitlab::Git::TAG_REF_PREFIX])
end
end
diff --git a/app/models/design_management/repository.rb b/app/models/design_management/repository.rb
index 39077fdbcb1..7410944e174 100644
--- a/app/models/design_management/repository.rb
+++ b/app/models/design_management/repository.rb
@@ -8,7 +8,7 @@ module DesignManagement
belongs_to :project, inverse_of: :design_management_repository
validates :project, presence: true, uniqueness: true
- delegate :lfs_enabled?, :storage, :repository_storage, to: :project
+ delegate :lfs_enabled?, :storage, :repository_storage, :run_after_commit, to: :project
def repository
::DesignManagement::GitRepository.new(
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 8480272eced..241b454f5ce 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -18,7 +18,7 @@ class Environment < ApplicationRecord
belongs_to :cluster_agent, class_name: 'Clusters::Agent', optional: true, inverse_of: :environments
use_fast_destroy :all_deployments
- nullify_if_blank :external_url
+ nullify_if_blank :external_url, :kubernetes_namespace
has_many :all_deployments, class_name: 'Deployment'
has_many :deployments, -> { visible }
@@ -70,13 +70,15 @@ class Environment < ApplicationRecord
length: { maximum: 255 },
allow_nil: true
- # Currently, the tier presence is validaed for newly created environments.
- # After the `BackfillEnvironmentTiers` background migration has been completed, we should remove `on: :create`.
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/385253.
- # Todo: Remove along with FF `validate_environment_tier_presence`.
- validates :tier, presence: true, on: :create, unless: :validate_environment_tier_present?
+ validates :kubernetes_namespace,
+ allow_nil: true,
+ length: 1..63,
+ format: {
+ with: Gitlab::Regex.kubernetes_namespace_regex,
+ message: Gitlab::Regex.kubernetes_namespace_regex_message
+ }
- validates :tier, presence: true, if: :validate_environment_tier_present?
+ validates :tier, presence: true
validate :safe_external_url
validate :merge_request_not_changed
@@ -602,10 +604,6 @@ class Environment < ApplicationRecord
self.class.tiers[:other]
end
end
-
- def validate_environment_tier_present?
- Feature.enabled?(:validate_environment_tier_presence, self.project)
- end
end
Environment.prepend_mod_with('Environment')
diff --git a/app/models/external_issue.rb b/app/models/external_issue.rb
index 36030b80370..06dc9cad5f9 100644
--- a/app/models/external_issue.rb
+++ b/app/models/external_issue.rb
@@ -44,7 +44,7 @@ class ExternalIssue
end
def reference_link_text(from = nil)
- return "##{id}" if id =~ /^\d+$/
+ return "##{id}" if /^\d+$/.match?(id)
id
end
diff --git a/app/models/external_pull_request.rb b/app/models/external_pull_request.rb
deleted file mode 100644
index 94c242782c1..00000000000
--- a/app/models/external_pull_request.rb
+++ /dev/null
@@ -1,106 +0,0 @@
-# frozen_string_literal: true
-
-# This model stores pull requests coming from external providers, such as
-# GitHub, when GitLab project is set as CI/CD only and remote mirror.
-#
-# When setting up a remote mirror with GitHub we subscribe to push and
-# pull_request webhook events. When a pull request is opened on GitHub,
-# a webhook is sent out, we create or update the status of the pull
-# request locally.
-#
-# When the mirror is updated and changes are pushed to branches we check
-# if there are open pull requests for the source and target branch.
-# If so, we create pipelines for external pull requests.
-class ExternalPullRequest < Ci::ApplicationRecord
- include Gitlab::Utils::StrongMemoize
- include ShaAttribute
- include EachBatch
-
- belongs_to :project
-
- sha_attribute :source_sha
- sha_attribute :target_sha
-
- validates :source_branch, presence: true
- validates :target_branch, presence: true
- validates :source_sha, presence: true
- validates :target_sha, presence: true
- validates :source_repository, presence: true
- validates :target_repository, presence: true
- validates :status, presence: true
-
- enum status: {
- open: 1,
- closed: 2
- }
-
- # We currently don't support pull requests from fork, so
- # we are going to return an error to the webhook
- validate :not_from_fork
-
- scope :by_source_branch, ->(branch) { where(source_branch: branch) }
- scope :by_source_repository, -> (repository) { where(source_repository: repository) }
-
- # Needed to override Ci::ApplicationRecord as this does not have ci_ table prefix
- self.table_name = 'external_pull_requests'
-
- def self.create_or_update_from_params(params)
- find_params = params.slice(:project_id, :source_branch, :target_branch)
-
- safe_find_or_initialize_and_update(find: find_params, update: params) do |pull_request|
- yield(pull_request) if block_given?
- end
- end
-
- def actual_branch_head?
- actual_source_branch_sha == source_sha
- end
-
- def from_fork?
- source_repository != target_repository
- end
-
- def source_ref
- Gitlab::Git::BRANCH_REF_PREFIX + source_branch
- end
-
- def predefined_variables
- Gitlab::Ci::Variables::Collection.new.tap do |variables|
- variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_IID', value: pull_request_iid.to_s)
- variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_REPOSITORY', value: source_repository)
- variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_TARGET_REPOSITORY', value: target_repository)
- variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_SHA', value: source_sha)
- variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_SHA', value: target_sha)
- variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_NAME', value: source_branch)
- variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_NAME', value: target_branch)
- end
- end
-
- def modified_paths
- project.repository.diff_stats(target_sha, source_sha).paths
- end
-
- private
-
- def actual_source_branch_sha
- project.commit(source_ref)&.sha
- end
-
- def not_from_fork
- if from_fork?
- errors.add(:base, _('Pull requests from fork are not supported'))
- end
- end
-
- def self.safe_find_or_initialize_and_update(find:, update:)
- safe_ensure_unique(retries: 1) do
- model = find_or_initialize_by(find)
-
- if model.update(update)
- yield(model) if block_given?
- end
-
- model
- end
- end
-end
diff --git a/app/models/group.rb b/app/models/group.rb
index 85971c48567..2b5a392e02c 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -510,7 +510,9 @@ class Group < Namespace
members_with_parents(only_active_users: false)
end
- members_from_hiearchy.all_owners.left_outer_joins(:user).merge(User.without_project_bot)
+ members_from_hiearchy.all_owners.left_outer_joins(:user)
+ .merge(User.without_project_bot)
+ .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417455")
end
def ldap_synced?
@@ -663,13 +665,24 @@ class Group < Namespace
# 2. They belong to a project that belongs to the group
# 3. They belong to a sub-group or project in such sub-group
# 4. They belong to an ancestor group
- def direct_and_indirect_users
+ # 5. They belong to a group that is shared with this group, if share_with_groups is true
+ def direct_and_indirect_users(share_with_groups: false)
+ members = if share_with_groups
+ # We only need :user_id column, but
+ # `members_from_self_and_ancestor_group_shares` needs more
+ # columns to make the CTE query work.
+ GroupMember.from_union([
+ direct_and_indirect_members.select(:user_id, :source_type, :type),
+ members_from_self_and_ancestor_group_shares.reselect(:user_id, :source_type, :type)
+ ])
+ else
+ direct_and_indirect_members
+ end
+
User.from_union([
- User
- .where(id: direct_and_indirect_members.select(:user_id))
- .reorder(nil),
- project_users_with_descendants
- ])
+ User.where(id: members.select(:user_id)).reorder(nil),
+ project_users_with_descendants
+ ])
end
# Returns all users (also inactive) that are members of the group because:
@@ -683,7 +696,7 @@ class Group < Namespace
.where(id: direct_and_indirect_members_with_inactive.select(:user_id))
.reorder(nil),
project_users_with_descendants
- ])
+ ]).allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417455") # failed in spec/tasks/gitlab/user_management_rake_spec.rb
end
def users_count
@@ -696,6 +709,7 @@ class Group < Namespace
User
.joins(projects: :group)
.where(namespaces: { id: self_and_descendants.select(:id) })
+ .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417455")
end
# Return the highest access level for a user
@@ -802,8 +816,11 @@ class Group < Namespace
end
def execute_integrations(data, hooks_scope)
- # NOOP
- # TODO: group hooks https://gitlab.com/gitlab-org/gitlab/-/issues/216904
+ return unless Feature.enabled?(:group_mentions, self)
+
+ integrations.public_send(hooks_scope).each do |integration| # rubocop:disable GitlabSecurity/PublicSend
+ integration.async_execute(data)
+ end
end
def preload_shared_group_links
@@ -813,16 +830,6 @@ class Group < Namespace
).call
end
- def update_shared_runners_setting!(state)
- raise ArgumentError unless SHARED_RUNNERS_SETTINGS.include?(state)
-
- case state
- when SR_DISABLED_AND_UNOVERRIDABLE then disable_shared_runners! # also disallows 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
-
def first_owner
owners.first || parent&.first_owner || owner
end
@@ -969,12 +976,14 @@ class Group < Namespace
end
def max_member_access(user_ids)
- Gitlab::SafeRequestLoader.execute(
- resource_key: max_member_access_for_resource_key(User),
- resource_ids: user_ids,
- default_value: Gitlab::Access::NO_ACCESS
- ) do |user_ids|
- members_with_parents.where(user_id: user_ids).group(:user_id).maximum(:access_level)
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417455") do
+ Gitlab::SafeRequestLoader.execute(
+ resource_key: max_member_access_for_resource_key(User),
+ resource_ids: user_ids,
+ default_value: Gitlab::Access::NO_ACCESS
+ ) do |user_ids|
+ members_with_parents.where(user_id: user_ids).group(:user_id).maximum(:access_level)
+ end
end
end
@@ -1057,45 +1066,6 @@ class Group < Namespace
Arel::Nodes::SqlLiteral.new(column_alias))
end
- def disable_shared_runners!
- update!(
- shared_runners_enabled: false,
- allow_descendants_override_disabled_shared_runners: false)
-
- group_ids = descendants
- unless group_ids.empty?
- Group.by_id(group_ids).update_all(
- shared_runners_enabled: false,
- allow_descendants_override_disabled_shared_runners: false)
- end
-
- all_projects.update_all(shared_runners_enabled: false)
- end
-
- def disable_shared_runners_and_allow_override!
- # enabled -> disabled_and_overridable
- if shared_runners_enabled?
- update!(
- shared_runners_enabled: false,
- allow_descendants_override_disabled_shared_runners: true)
-
- group_ids = descendants
- unless group_ids.empty?
- Group.by_id(group_ids).update_all(shared_runners_enabled: false)
- end
-
- all_projects.update_all(shared_runners_enabled: false)
-
- # disabled_and_unoverridable -> disabled_and_overridable
- else
- update!(allow_descendants_override_disabled_shared_runners: true)
- end
- end
-
- def enable_shared_runners!
- update!(shared_runners_enabled: true)
- end
-
def runners_token_prefix
RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX
end
diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb
index 695041f0247..05c5ad22218 100644
--- a/app/models/hooks/project_hook.rb
+++ b/app/models/hooks/project_hook.rb
@@ -21,7 +21,8 @@ class ProjectHook < WebHook
:wiki_page_hooks,
:deployment_hooks,
:feature_flag_hooks,
- :release_hooks
+ :release_hooks,
+ :emoji_hooks
]
belongs_to :project
diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb
index e08294058e4..4c35f699468 100644
--- a/app/models/hooks/web_hook_log.rb
+++ b/app/models/hooks/web_hook_log.rb
@@ -66,7 +66,7 @@ class WebHookLog < ApplicationRecord
def redact_user_emails
self.request_data.deep_transform_values! do |value|
- value.to_s =~ URI::MailTo::EMAIL_REGEXP ? _('[REDACTED]') : value
+ URI::MailTo::EMAIL_REGEXP.match?(value.to_s) ? _('[REDACTED]') : value
end
end
diff --git a/app/models/integration.rb b/app/models/integration.rb
index f2f242136ab..f823a385022 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -90,6 +90,8 @@ class Integration < ApplicationRecord
attribute :push_events, default: true
attribute :tag_push_events, default: true
attribute :wiki_page_events, default: true
+ attribute :group_mention_events, default: false
+ attribute :group_confidential_mention_events, default: false
after_initialize :initialize_properties
@@ -137,6 +139,8 @@ class Integration < ApplicationRecord
scope :alert_hooks, -> { where(alert_events: true, active: true) }
scope :incident_hooks, -> { where(incident_events: true, active: true) }
scope :deployment, -> { where(category: 'deployment') }
+ scope :group_mention_hooks, -> { where(group_mention_events: true, active: true) }
+ scope :group_confidential_mention_hooks, -> { where(group_confidential_mention_events: true, active: true) }
class << self
private
@@ -586,6 +590,7 @@ class Integration < ApplicationRecord
end
def async_execute(data)
+ return if ::Gitlab::SilentMode.enabled?
return unless supported_events.include?(data[:object_kind])
Integrations::ExecuteWorker.perform_async(id, data)
@@ -600,6 +605,10 @@ class Integration < ApplicationRecord
category == :chat
end
+ def ci?
+ category == :ci
+ end
+
private
# Ancestors sorted by hierarchy depth in bottom-top order.
diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb
index 4477f3d207f..c9de4d2b3bb 100644
--- a/app/models/integrations/base_chat_notification.rb
+++ b/app/models/integrations/base_chat_notification.rb
@@ -262,11 +262,11 @@ module Integrations
end
def project_name
- project.full_name
+ project.try(:full_name)
end
def project_url
- project.web_url
+ project.try(:web_url)
end
def update?(data)
diff --git a/app/models/integrations/base_slack_notification.rb b/app/models/integrations/base_slack_notification.rb
index c83a559e0da..29a20419809 100644
--- a/app/models/integrations/base_slack_notification.rb
+++ b/app/models/integrations/base_slack_notification.rb
@@ -7,6 +7,8 @@ module Integrations
].freeze
prop_accessor EVENT_CHANNEL['alert']
+ prop_accessor EVENT_CHANNEL['group_mention']
+ prop_accessor EVENT_CHANNEL['group_confidential_mention']
override :default_channel_placeholder
def default_channel_placeholder
@@ -16,15 +18,20 @@ module Integrations
override :get_message
def get_message(object_kind, data)
return Integrations::ChatMessage::AlertMessage.new(data) if object_kind == 'alert'
+ return Integrations::ChatMessage::GroupMentionMessage.new(data) if object_kind == 'group_mention'
super
end
override :supported_events
def supported_events
- additional = ['alert']
+ additional = %w[alert]
- super + additional
+ if group_level? && Feature.enabled?(:group_mentions, group)
+ additional += %w[group_mention group_confidential_mention]
+ end
+
+ (super + additional).freeze
end
override :configurable_channels?
diff --git a/app/models/integrations/chat_message/group_mention_message.rb b/app/models/integrations/chat_message/group_mention_message.rb
new file mode 100644
index 00000000000..a2bc00ddbd9
--- /dev/null
+++ b/app/models/integrations/chat_message/group_mention_message.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+module Integrations
+ module ChatMessage
+ class GroupMentionMessage < BaseMessage
+ ISSUE_KIND = 'issue'
+ MR_KIND = 'merge_request'
+ NOTE_KIND = 'note'
+
+ KNOWN_KINDS = [ISSUE_KIND, MR_KIND, NOTE_KIND].freeze
+
+ def initialize(params)
+ super
+ params = HashWithIndifferentAccess.new(params)
+
+ @group_name, @group_url = params[:mentioned].values_at(:name, :url)
+ @detail = nil
+
+ obj_attr = params[:object_attributes]
+ obj_kind = obj_attr[:object_kind]
+ raise NotImplementedError unless KNOWN_KINDS.include?(obj_kind)
+
+ case obj_kind
+ when 'issue'
+ @source_name, @title = get_source_for_issue(obj_attr)
+ @detail = obj_attr[:description]
+ when 'merge_request'
+ @source_name, @title = get_source_for_merge_request(obj_attr)
+ @detail = obj_attr[:description]
+ when 'note'
+ if params[:commit]
+ @source_name, @title = get_source_for_commit(params[:commit])
+ elsif params[:issue]
+ @source_name, @title = get_source_for_issue(params[:issue])
+ elsif params[:merge_request]
+ @source_name, @title = get_source_for_merge_request(params[:merge_request])
+ else
+ raise NotImplementedError
+ end
+
+ @detail = obj_attr[:note]
+ end
+
+ @source_url = obj_attr[:url]
+ end
+
+ def attachments
+ if markdown
+ detail
+ else
+ [{ text: format(detail), color: attachment_color }]
+ end
+ end
+
+ def activity
+ {
+ title: "Group #{group_link} was mentioned in #{source_link}",
+ subtitle: "of #{project_link}",
+ text: strip_markup(formatted_title),
+ image: user_avatar
+ }
+ end
+
+ private
+
+ attr_reader :group_name, :group_url, :source_name, :source_url, :title, :detail
+
+ def get_source_for_commit(params)
+ commit_sha = Commit.truncate_sha(params[:id])
+ ["commit #{commit_sha}", params[:title]]
+ end
+
+ def get_source_for_issue(params)
+ ["issue ##{params[:iid]}", params[:title]]
+ end
+
+ def get_source_for_merge_request(params)
+ ["merge request !#{params[:iid]}", params[:title]]
+ end
+
+ def message
+ "Group #{group_link} was mentioned in #{source_link} of #{project_link}: *#{formatted_title}*"
+ end
+
+ def formatted_title
+ strip_markup(title.lines.first.chomp)
+ end
+
+ def group_link
+ link(group_name, group_url)
+ end
+
+ def source_link
+ link(source_name, source_url)
+ end
+
+ def project_link
+ link(project_name, project_url)
+ end
+ end
+ end
+end
diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb
index ad82f1b916f..7ba9bbc38e6 100644
--- a/app/models/integrations/hangouts_chat.rb
+++ b/app/models/integrations/hangouts_chat.rb
@@ -2,6 +2,23 @@
module Integrations
class HangoutsChat < BaseChatNotification
+ undef :notify_only_broken_pipelines
+
+ field :webhook,
+ section: SECTION_TYPE_CONNECTION,
+ help: 'https://chat.googleapis.com/v1/spaces…',
+ required: true
+
+ field :notify_only_broken_pipelines,
+ type: 'checkbox',
+ section: SECTION_TYPE_CONFIGURATION
+
+ field :branches_to_be_notified,
+ type: 'select',
+ section: SECTION_TYPE_CONFIGURATION,
+ title: -> { s_('Integrations|Branches for which notifications are to be sent') },
+ choices: -> { branch_choices }
+
def title
'Google Chat'
end
@@ -19,25 +36,15 @@ module Integrations
s_('Before enabling this integration, create a webhook for the room in Google Chat where you want to receive notifications from this project. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
- def default_channel_placeholder
+ def fields
+ self.class.fields + build_event_channels
end
- def self.supported_events
- %w[push issue confidential_issue merge_request note confidential_note tag_push
- pipeline wiki_page]
+ def default_channel_placeholder
end
- def default_fields
- [
- { type: 'text', name: 'webhook', help: 'https://chat.googleapis.com/v1/spaces…' },
- { type: 'checkbox', name: 'notify_only_broken_pipelines' },
- {
- type: 'select',
- name: 'branches_to_be_notified',
- title: s_('Integrations|Branches for which notifications are to be sent'),
- choices: self.class.branch_choices
- }
- ]
+ def self.supported_events
+ %w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page]
end
private
diff --git a/app/models/integrations/microsoft_teams.rb b/app/models/integrations/microsoft_teams.rb
index d6cbe5760e8..a9ed0bd3da1 100644
--- a/app/models/integrations/microsoft_teams.rb
+++ b/app/models/integrations/microsoft_teams.rb
@@ -2,6 +2,24 @@
module Integrations
class MicrosoftTeams < BaseChatNotification
+ undef :notify_only_broken_pipelines
+
+ field :webhook,
+ section: SECTION_TYPE_CONNECTION,
+ help: 'https://outlook.office.com/webhook/…',
+ required: true
+
+ field :notify_only_broken_pipelines,
+ type: 'checkbox',
+ section: SECTION_TYPE_CONFIGURATION,
+ help: 'If selected, successful pipelines do not trigger a notification event.'
+
+ field :branches_to_be_notified,
+ type: 'select',
+ section: SECTION_TYPE_CONFIGURATION,
+ title: -> { s_('Integrations|Branches for which notifications are to be sent') },
+ choices: -> { branch_choices }
+
def title
'Microsoft Teams notifications'
end
@@ -26,23 +44,8 @@ module Integrations
pipeline wiki_page]
end
- def default_fields
- [
- { type: 'text', section: SECTION_TYPE_CONNECTION, name: 'webhook', help: 'https://outlook.office.com/webhook/…', required: true },
- {
- type: 'checkbox',
- section: SECTION_TYPE_CONFIGURATION,
- name: 'notify_only_broken_pipelines',
- help: 'If selected, successful pipelines do not trigger a notification event.'
- },
- {
- 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
- }
- ]
+ def fields
+ self.class.fields + build_event_channels
end
def sections
diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb
index 2dc0fd7d011..8969c6c13b2 100644
--- a/app/models/integrations/prometheus.rb
+++ b/app/models/integrations/prometheus.rb
@@ -15,7 +15,7 @@ module Integrations
title: 'API URL',
placeholder: -> { s_('PrometheusService|https://prometheus.example.com/') },
help: -> { s_('PrometheusService|The Prometheus API base URL.') },
- required: true
+ required: false
field :google_iap_audience_client_id,
title: 'Google IAP Audience Client ID',
@@ -34,8 +34,8 @@ module Integrations
# to allow localhost URLs when the following conditions are true:
# 1. api_url is the internal Prometheus URL.
with_options presence: true do
- validates :api_url, public_url: true, if: ->(object) { object.manual_configuration? && !object.allow_local_api_url? }
- validates :api_url, url: true, if: ->(object) { object.manual_configuration? && object.allow_local_api_url? }
+ validates :api_url, public_url: true, if: ->(object) { object.api_url.present? && object.manual_configuration? && !object.allow_local_api_url? }
+ validates :api_url, url: true, if: ->(object) { object.api_url.present? && object.manual_configuration? && object.allow_local_api_url? }
end
before_save :synchronize_service_state
diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb
index aa19133b8c2..6c447c8f4e4 100644
--- a/app/models/integrations/unify_circuit.rb
+++ b/app/models/integrations/unify_circuit.rb
@@ -2,6 +2,23 @@
module Integrations
class UnifyCircuit < BaseChatNotification
+ undef :notify_only_broken_pipelines
+
+ field :webhook,
+ section: SECTION_TYPE_CONNECTION,
+ help: 'https://yourcircuit.com/rest/v2/webhooks/incoming/…',
+ required: true
+
+ field :notify_only_broken_pipelines,
+ type: 'checkbox',
+ section: SECTION_TYPE_CONFIGURATION
+
+ field :branches_to_be_notified,
+ type: 'select',
+ section: SECTION_TYPE_CONFIGURATION,
+ title: -> { s_('Integrations|Branches for which notifications are to be sent') },
+ choices: -> { branch_choices }
+
def title
'Unify Circuit'
end
@@ -14,6 +31,10 @@ module Integrations
'unify_circuit'
end
+ def fields
+ self.class.fields + build_event_channels
+ end
+
def help
docs_link = ActionController::Base.helpers.link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/unify_circuit'), target: '_blank', rel: 'noopener noreferrer'
s_('Integrations|Send notifications about project events to a Unify Circuit conversation. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
@@ -27,19 +48,6 @@ module Integrations
pipeline wiki_page]
end
- def default_fields
- [
- { type: 'text', name: 'webhook', help: 'https://yourcircuit.com/rest/v2/webhooks/incoming/…', required: true },
- { type: 'checkbox', name: 'notify_only_broken_pipelines' },
- {
- type: 'select',
- name: 'branches_to_be_notified',
- title: s_('Integrations|Branches for which notifications are to be sent'),
- choices: self.class.branch_choices
- }
- ]
- end
-
private
def notify(message, opts)
diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb
index 8e6f5ca6d17..ef1bc81ea58 100644
--- a/app/models/integrations/webex_teams.rb
+++ b/app/models/integrations/webex_teams.rb
@@ -2,6 +2,23 @@
module Integrations
class WebexTeams < BaseChatNotification
+ undef :notify_only_broken_pipelines
+
+ field :webhook,
+ section: SECTION_TYPE_CONNECTION,
+ help: 'https://api.ciscospark.com/v1/webhooks/incoming/...',
+ required: true
+
+ field :notify_only_broken_pipelines,
+ type: 'checkbox',
+ section: SECTION_TYPE_CONFIGURATION
+
+ field :branches_to_be_notified,
+ type: 'select',
+ section: SECTION_TYPE_CONFIGURATION,
+ title: -> { s_('Integrations|Branches for which notifications are to be sent') },
+ choices: -> { branch_choices }
+
def title
s_("WebexTeamsService|Webex Teams")
end
@@ -14,6 +31,10 @@ module Integrations
'webex_teams'
end
+ def fields
+ self.class.fields + build_event_channels
+ end
+
def help
docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/webex_teams'), target: '_blank', rel: 'noopener noreferrer'
s_("WebexTeamsService|Send notifications about project events to a Webex Teams conversation. %{docs_link}") % { docs_link: docs_link.html_safe }
@@ -23,21 +44,7 @@ module Integrations
end
def self.supported_events
- %w[push issue confidential_issue merge_request note confidential_note tag_push
- pipeline wiki_page]
- end
-
- def default_fields
- [
- { type: 'text', name: 'webhook', help: 'https://api.ciscospark.com/v1/webhooks/incoming/...', required: true },
- { type: 'checkbox', name: 'notify_only_broken_pipelines' },
- {
- type: 'select',
- name: 'branches_to_be_notified',
- title: s_('Integrations|Branches for which notifications are to be sent'),
- choices: self.class.branch_choices
- }
- ]
+ %w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page]
end
private
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 890af8a27a0..6e48dcab9ed 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -56,6 +56,8 @@ class Issue < ApplicationRecord
# This default came from the enum `issue_type` column. Defined as default in the DB
DEFAULT_ISSUE_TYPE = :issue
+ ignore_column :issue_type, remove_with: '16.4', remove_after: '2023-08-22'
+
belongs_to :project
belongs_to :namespace, inverse_of: :issues
@@ -133,12 +135,6 @@ class Issue < ApplicationRecord
validate :allowed_work_item_type_change, on: :update, if: :work_item_type_id_changed?
validate :due_date_after_start_date
validate :parent_link_confidentiality
- # using a custom validation since we are overwriting the `issue_type` method to use the work_item_types table
- validate :issue_type_attribute_present
-
- enum issue_type: WorkItems::Type.base_types
- # TODO: Remove with https://gitlab.com/gitlab-org/gitlab/-/issues/402699
- include ::Issues::ForbidIssueTypeColumnUsage
alias_method :issuing_parent, :project
alias_attribute :issuing_parent_id, :project_id
@@ -187,7 +183,10 @@ class Issue < ApplicationRecord
scope :order_closed_at_desc, -> { reorder(arel_table[:closed_at].desc.nulls_last) }
scope :preload_associated_models, -> { preload(:assignees, :labels, project: :namespace) }
- scope :with_web_entity_associations, -> { preload(:author, :namespace, project: [:project_feature, :route, namespace: :route]) }
+ scope :with_web_entity_associations, -> do
+ preload(:author, :namespace, :labels, project: [:project_feature, :route, namespace: :route])
+ end
+
scope :preload_awardable, -> { preload(:award_emoji) }
scope :with_alert_management_alerts, -> { joins(:alert_management_alert) }
scope :with_prometheus_alert_events, -> { joins(:issues_prometheus_alert_events) }
@@ -201,24 +200,17 @@ class Issue < ApplicationRecord
scope :with_issue_type, ->(types) {
types = Array(types)
- if Feature.enabled?(:issue_type_uses_work_item_types_table)
- # Using != 1 since we also want the guard clause to handle empty arrays
- return joins(:work_item_type).where(work_item_types: { base_type: types }) if types.size != 1
+ # Using != 1 since we also want the guard clause to handle empty arrays
+ return joins(:work_item_type).where(work_item_types: { base_type: types }) if types.size != 1
- where(
- '"issues"."work_item_type_id" = (?)',
- WorkItems::Type.by_type(types.first).select(:id).limit(1)
- )
- else
- where(issue_type: types)
- end
+ # This optimization helps the planer use the correct indexes when filtering by a single type
+ where(
+ '"issues"."work_item_type_id" = (?)',
+ WorkItems::Type.by_type(types.first).select(:id).limit(1)
+ )
}
scope :without_issue_type, ->(types) {
- if Feature.enabled?(:issue_type_uses_work_item_types_table)
- joins(:work_item_type).where.not(work_item_types: { base_type: types })
- else
- where.not(issue_type: types)
- end
+ joins(:work_item_type).where.not(work_item_types: { base_type: types })
}
scope :public_only, -> { where(confidential: false) }
@@ -258,7 +250,6 @@ class Issue < ApplicationRecord
scope :with_projects_matching_search_data, -> { where('issue_search_data.project_id = issues.project_id') }
before_validation :ensure_namespace_id, :ensure_work_item_type
- before_save :check_issue_type_in_sync!
after_save :ensure_metrics!, unless: :importing?
after_commit :expire_etag_cache, unless: :importing?
@@ -588,16 +579,12 @@ class Issue < ApplicationRecord
user, project.external_authorization_classification_label)
end
- def check_for_spam?(user:)
- # content created via support bots is always checked for spam, EVEN if
- # the issue is not publicly visible and/or confidential
- return true if user.support_bot? && spammable_attribute_changed?
-
- # Only check for spam on issues which are publicly visible (and thus indexed in search engines)
- return false unless publicly_visible?
+ # Always enforce spam check for support bot but allow for other users when issue is not publicly visible
+ def allow_possible_spam?(user)
+ return true if Gitlab::CurrentSettings.allow_possible_spam
+ return false if user.support_bot?
- # Only check for spam if certain attributes have changed
- spammable_attribute_changed?
+ !publicly_visible?
end
def supports_recaptcha?
@@ -753,11 +740,7 @@ class Issue < ApplicationRecord
end
def issue_type
- if ::Feature.enabled?(:issue_type_uses_work_item_types_table)
- work_item_type_with_default.base_type
- else
- super
- end
+ work_item_type_with_default.base_type
end
def unsubscribe_email_participant(email)
@@ -766,41 +749,11 @@ class Issue < ApplicationRecord
issue_email_participants.find_by_email(email)&.destroy
end
- private
-
- def check_issue_type_in_sync!
- # We might have existing records out of sync, so we need to skip this check unless the value is changed
- # so those records can still be updated until we fix them and remove the issue_type column
- # https://gitlab.com/gitlab-org/gitlab/-/work_items/403158
- return unless (changes.keys & %w[issue_type work_item_type_id]).any?
-
- # Do not replace the use of attributes with `issue_type` here
- if attributes['issue_type'] != work_item_type.base_type
- error = IssueTypeOutOfSyncError.new(
- <<~ERROR
- Issue `issue_type` out of sync with `work_item_type_id` column.
- `issue_type` must be equal to `work_item.base_type`.
- You can assign the correct work_item_type like this for example:
-
- Issue.new(issue_type: :incident, work_item_type: WorkItems::Type.default_by_type(:incident))
-
- More details in https://gitlab.com/gitlab-org/gitlab/-/issues/338005
- ERROR
- )
-
- Gitlab::ErrorTracking.track_and_raise_for_dev_exception(
- error,
- issue_type: attributes['issue_type'],
- work_item_type_id: work_item_type_id
- )
- end
+ def hook_attrs
+ Gitlab::HookData::IssueBuilder.new(self).build
end
- def issue_type_attribute_present
- return if attributes['issue_type'].present?
-
- errors.add(:issue_type, 'Must be present')
- end
+ private
def due_date_after_start_date
return unless start_date.present? && due_date.present?
@@ -834,12 +787,6 @@ class Issue < ApplicationRecord
Issues::SearchData.upsert({ project_id: project_id, issue_id: id, search_vector: search_vector }, unique_by: %i(project_id issue_id))
end
- def spammable_attribute_changed?
- # NOTE: We need to check them for spam when issues are made non-confidential, because spam
- # may have been added while they were confidential and thus not being checked for spam.
- super || confidential_changed?(from: true, to: false)
- end
-
def ensure_metrics!
Issue::Metrics.record!(self)
end
@@ -868,9 +815,7 @@ class Issue < ApplicationRecord
def ensure_work_item_type
return if work_item_type_id.present? || work_item_type_id_change&.last.present?
- # TODO: We should switch to DEFAULT_ISSUE_TYPE here when the issue_type column is dropped
- # https://gitlab.com/gitlab-org/gitlab/-/work_items/402700
- self.work_item_type = WorkItems::Type.default_by_type(attributes['issue_type'])
+ self.work_item_type = WorkItems::Type.default_by_type(DEFAULT_ISSUE_TYPE)
end
def allowed_work_item_type_change
diff --git a/app/models/member.rb b/app/models/member.rb
index 0700b1a8448..f164ea244b4 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -66,6 +66,7 @@ class Member < ApplicationRecord
scope :with_invited_user_state, -> do
joins('LEFT JOIN users as invited_user ON invited_user.email = members.invite_email')
.select('members.*', 'invited_user.state as invited_user_state')
+ .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417456")
end
scope :in_hierarchy, ->(source) do
@@ -174,7 +175,10 @@ class Member < ApplicationRecord
scope :by_access_level, -> (access_level) { active.where(access_level: access_level) }
scope :all_by_access_level, -> (access_level) { where(access_level: access_level) }
- scope :preload_user_and_notification_settings, -> { preload(user: :notification_settings) }
+ scope :preload_user_and_notification_settings, -> do
+ preload(user: :notification_settings)
+ .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417456")
+ end
scope :with_source_id, ->(source_id) { where(source_id: source_id) }
scope :including_source, -> { includes(:source) }
@@ -288,7 +292,9 @@ class Member < ApplicationRecord
class << self
def search(query)
- scope = joins(:user).merge(User.search(query, use_minimum_char_limit: false))
+ scope = joins(:user)
+ .merge(User.search(query, use_minimum_char_limit: false))
+ .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417456")
return scope unless Gitlab::Pagination::Keyset::Order.keyset_aware?(scope)
@@ -347,6 +353,7 @@ class Member < ApplicationRecord
def left_join_users
left_outer_joins(:user)
+ .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417456")
end
def access_for_user_ids(user_ids)
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index 237054587bc..ada89345a7f 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -20,7 +20,6 @@ class GroupMember < Member
scope :of_groups, ->(groups) { where(source_id: groups&.select(:id)) }
scope :of_ldap_type, -> { where(ldap: true) }
scope :count_users_by_group_id, -> { group(:source_id).count }
- scope :with_user, -> (user) { where(user: user) }
after_create :update_two_factor_requirement, unless: :invite?
after_destroy :update_two_factor_requirement, unless: :invite?
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 116108ceaf9..2773569161d 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -421,7 +421,9 @@ class MergeRequest < ApplicationRecord
scope :preload_latest_diff_commit, -> { preload(latest_merge_request_diff: { merge_request_diff_commits: [:commit_author, :committer] }) }
scope :preload_milestoneish_associations, -> { preload_routables.preload(:assignees, :labels) }
- scope :with_web_entity_associations, -> { preload(:author, target_project: [:project_feature, group: [:route, :parent], namespace: :route]) }
+ scope :with_web_entity_associations, -> do
+ preload(:author, :labels, target_project: [:project_feature, group: [:route, :parent], namespace: :route])
+ end
scope :with_auto_merge_enabled, -> do
with_state(:opened).where(auto_merge_enabled: true)
@@ -1199,10 +1201,17 @@ class MergeRequest < ApplicationRecord
end
alias_method :wip_title, :draft_title
- def mergeable?(skip_ci_check: false, skip_discussions_check: false)
+ def skipped_mergeable_checks(options = {})
+ {
+ skip_ci_check: options.fetch(:auto_merge_requested, false)
+ }
+ end
+
+ def mergeable?(skip_ci_check: false, skip_discussions_check: false, skip_approved_check: false)
return false unless mergeable_state?(
skip_ci_check: skip_ci_check,
- skip_discussions_check: skip_discussions_check
+ skip_discussions_check: skip_discussions_check,
+ skip_approved_check: skip_approved_check
)
check_mergeability
@@ -1223,11 +1232,12 @@ class MergeRequest < ApplicationRecord
]
end
- def mergeable_state?(skip_ci_check: false, skip_discussions_check: false)
+ def mergeable_state?(skip_ci_check: false, skip_discussions_check: false, skip_approved_check: false)
additional_checks = execute_merge_checks(
params: {
skip_ci_check: skip_ci_check,
- skip_discussions_check: skip_discussions_check
+ skip_discussions_check: skip_discussions_check,
+ skip_approved_check: skip_approved_check
}
)
additional_checks.success?
@@ -1526,6 +1536,14 @@ class MergeRequest < ApplicationRecord
"refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/train"
end
+ def schedule_cleanup_refs(only: :all)
+ if Feature.enabled?(:merge_request_cleanup_ref_worker_async, target_project)
+ MergeRequests::CleanupRefWorker.perform_async(id, only.to_s)
+ else
+ cleanup_refs(only: only)
+ end
+ end
+
def cleanup_refs(only: :all)
target_refs = []
target_refs << ref_path if %i[all head].include?(only)
diff --git a/app/models/merge_request/diff_llm_summary.rb b/app/models/merge_request/diff_llm_summary.rb
deleted file mode 100644
index e13fe5e1f50..00000000000
--- a/app/models/merge_request/diff_llm_summary.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# rubocop:disable Style/ClassAndModuleChildren
-# frozen_string_literal: true
-
-class MergeRequest::DiffLlmSummary < ApplicationRecord
- belongs_to :merge_request_diff
- belongs_to :user, optional: true
-
- validates :merge_request_diff_id, uniqueness: true
- validates :provider, presence: true
- validates :content, presence: true, length: { maximum: 2056 }
-
- enum provider: { openai: 0 }
-end
-# rubocop:enable Style/ClassAndModuleChildren
diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb
index 70216144035..a13cb353c7b 100644
--- a/app/models/merge_request/metrics.rb
+++ b/app/models/merge_request/metrics.rb
@@ -13,7 +13,7 @@ class MergeRequest::Metrics < ApplicationRecord
before_save :ensure_target_project_id
scope :merged_after, ->(date) { where(arel_table[:merged_at].gteq(date)) }
- scope :merged_before, ->(date) { where(arel_table[:merged_at].lteq(date)) }
+ scope :merged_before, ->(date) { where(arel_table[:merged_at].lteq(date.is_a?(Time) ? date.end_of_day : date)) }
scope :with_valid_time_to_merge, -> { where(arel_table[:merged_at].gt(arel_table[:created_at])) }
scope :by_target_project, ->(project) { where(target_project_id: project) }
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index d300b938fc0..8de717fb61d 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -10,6 +10,7 @@ class Milestone < ApplicationRecord
include IidRoutes
include UpdatedAtFilterable
include EachBatch
+ include Spammable
prepend_mod_with('Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule
@@ -62,6 +63,9 @@ class Milestone < ApplicationRecord
validate :parent_type_check
validate :uniqueness_of_title, if: :title_changed?
+ attr_spammable :title, spam_title: true
+ attr_spammable :description, spam_description: true
+
state_machine :state, initial: :active do
event :close do
transition active: :closed
@@ -255,6 +259,10 @@ class Milestone < ApplicationRecord
end
end
+ def check_for_spam?(*)
+ spammable_attribute_changed? && parent.public?
+ end
+
private
def timebox_format_reference(format = :iid)
diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb
index d1277efac7b..5c5f8d3b2db 100644
--- a/app/models/ml/experiment.rb
+++ b/app/models/ml/experiment.rb
@@ -11,6 +11,7 @@ module Ml
belongs_to :project
belongs_to :user
+ belongs_to :model, optional: true, inverse_of: :default_experiment
has_many :candidates, class_name: 'Ml::Candidate'
has_many :metadata, class_name: 'Ml::ExperimentMetadata'
@@ -22,10 +23,21 @@ module Ml
has_internal_id :iid, scope: :project
+ before_destroy :stop_destroy
+
def package_name
"#{PACKAGE_PREFIX}#{iid}"
end
+ def stop_destroy
+ return unless model_id
+
+ errors[:base] << "Cannot delete an experiment associated to a model"
+ # According to docs, throw is the correct way to stop on a callback
+ # https://api.rubyonrails.org/classes/ActiveRecord/Callbacks.html#module-ActiveRecord::Callbacks-label-Canceling+callbacks
+ throw :abort # rubocop:disable Cop/BanCatchThrow
+ end
+
class << self
def by_project_id_and_iid(project_id, iid)
find_by(project_id: project_id, iid: iid)
diff --git a/app/models/ml/model.rb b/app/models/ml/model.rb
new file mode 100644
index 00000000000..684b8e1983b
--- /dev/null
+++ b/app/models/ml/model.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Ml
+ class Model < ApplicationRecord
+ validates :project, :default_experiment, presence: true
+ validates :name,
+ format: Gitlab::Regex.ml_model_name_regex,
+ uniqueness: { scope: :project },
+ presence: true,
+ length: { maximum: 255 }
+
+ validate :valid_default_experiment?
+
+ has_one :default_experiment, class_name: 'Ml::Experiment'
+ belongs_to :project
+ has_many :versions, class_name: 'Ml::ModelVersion'
+
+ def valid_default_experiment?
+ return unless default_experiment
+
+ errors.add(:default_experiment) unless default_experiment.name == name
+ errors.add(:default_experiment) unless default_experiment.project_id == project_id
+ end
+ end
+end
diff --git a/app/models/ml/model_version.rb b/app/models/ml/model_version.rb
new file mode 100644
index 00000000000..540fe6018a1
--- /dev/null
+++ b/app/models/ml/model_version.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Ml
+ class ModelVersion < ApplicationRecord
+ validates :project, :model, presence: true
+
+ validates :version,
+ format: Gitlab::Regex.ml_model_version_regex,
+ uniqueness: { scope: [:project, :model_id] },
+ presence: true,
+ length: { maximum: 255 }
+
+ validate :valid_model?, :valid_package?
+
+ belongs_to :model, class_name: 'Ml::Model'
+ belongs_to :project
+ belongs_to :package, class_name: 'Packages::Package', optional: true
+
+ delegate :name, to: :model
+
+ private
+
+ def valid_model?
+ return unless model
+
+ errors.add(:model, 'model project must be the same') unless model.project_id == project_id
+ end
+
+ def valid_package?
+ return unless package
+
+ errors.add(:package, 'package must be ml_model') unless package.ml_model?
+ errors.add(:package, 'package name must be the same') unless package.name == name
+ errors.add(:package, 'package version must be the same') unless package.version == version
+ errors.add(:package, 'package project must be the same') unless package.project_id == project_id
+ end
+ end
+end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 7b3bb04da5b..5449f006a2e 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -57,6 +57,7 @@ class Namespace < ApplicationRecord
# This should _not_ be `inverse_of: :namespace`, because that would also set
# `user.namespace` when this user creates a group with themselves as `owner`.
belongs_to :owner, class_name: 'User'
+ belongs_to :organization, class_name: 'Organizations::Organization'
belongs_to :parent, class_name: "Namespace"
has_many :children, -> { where(type: Group.sti_name) }, class_name: "Namespace", foreign_key: :parent_id
@@ -305,7 +306,7 @@ class Namespace < ApplicationRecord
end
def first_project_with_container_registry_tags
- if ContainerRegistry::GitlabApiClient.supports_gitlab_api?
+ if Gitlab.com_except_jh? && ContainerRegistry::GitlabApiClient.supports_gitlab_api?
ContainerRegistry::GitlabApiClient.one_project_with_container_registry_tag(full_path)
else
all_projects.includes(:container_repositories).find(&:has_container_registry_tags?)
@@ -423,6 +424,10 @@ class Namespace < ApplicationRecord
false
end
+ def all_project_ids
+ all_projects.pluck(:id)
+ end
+
def all_project_ids_except(ids)
all_projects.where.not(id: ids).pluck(:id)
end
@@ -478,7 +483,7 @@ class Namespace < ApplicationRecord
def container_repositories_size
strong_memoize(:container_repositories_size) do
- next unless Gitlab.com?
+ next unless Gitlab.com_except_jh?
next unless root?
next unless ContainerRegistry::GitlabApiClient.supports_gitlab_api?
next 0 if all_container_repositories.empty?
diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb
index 9006f104c64..1ca3c8e85f3 100644
--- a/app/models/namespaces/traversal/linear.rb
+++ b/app/models/namespaces/traversal/linear.rb
@@ -96,27 +96,6 @@ module Namespaces
traversal_ids.present?
end
- def use_traversal_ids_for_self_and_hierarchy?
- return false unless use_traversal_ids?
- return false unless Feature.enabled?(:use_traversal_ids_for_self_and_hierarchy, root_ancestor)
-
- traversal_ids.present?
- end
-
- def use_traversal_ids_for_ancestors?
- return false unless use_traversal_ids?
- return false unless Feature.enabled?(:use_traversal_ids_for_ancestors, root_ancestor)
-
- traversal_ids.present?
- end
-
- def use_traversal_ids_for_ancestors_upto?
- return false unless use_traversal_ids?
- return false unless Feature.enabled?(:use_traversal_ids_for_ancestors_upto, root_ancestor)
-
- traversal_ids.present?
- end
-
def root_ancestor
strong_memoize(:root_ancestor) do
if association(:parent).loaded? && parent.present?
@@ -150,13 +129,13 @@ module Namespaces
end
def self_and_hierarchy
- return super unless use_traversal_ids_for_self_and_hierarchy?
+ return super unless use_traversal_ids?
self_and_descendants.or(ancestors)
end
def ancestors(hierarchy_order: nil)
- return super unless use_traversal_ids_for_ancestors?
+ return super unless use_traversal_ids?
return self.class.none if parent_id.blank?
@@ -164,7 +143,7 @@ module Namespaces
end
def ancestor_ids(hierarchy_order: nil)
- return super unless use_traversal_ids_for_ancestors?
+ return super unless use_traversal_ids?
hierarchy_order == :desc ? traversal_ids[0..-2] : traversal_ids[0..-2].reverse
end
@@ -176,7 +155,7 @@ module Namespaces
# This copies the behavior of the recursive method. We will deprecate
# this behavior soon.
def ancestors_upto(top = nil, hierarchy_order: nil)
- return super unless use_traversal_ids_for_ancestors_upto?
+ return super unless use_traversal_ids?
# We can't use a default value in the method definition above because
# we need to preserve those specific parameters for super.
@@ -198,7 +177,7 @@ module Namespaces
end
def self_and_ancestors(hierarchy_order: nil)
- return super unless use_traversal_ids_for_ancestors?
+ return super unless use_traversal_ids?
return self.class.where(id: id) if parent_id.blank?
@@ -206,7 +185,7 @@ module Namespaces
end
def self_and_ancestor_ids(hierarchy_order: nil)
- return super unless use_traversal_ids_for_ancestors?
+ return super unless use_traversal_ids?
hierarchy_order == :desc ? traversal_ids : traversal_ids.reverse
end
diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb
index c50d3dd1de6..6e79e3ac9a1 100644
--- a/app/models/namespaces/traversal/linear_scopes.rb
+++ b/app/models/namespaces/traversal/linear_scopes.rb
@@ -18,7 +18,7 @@ module Namespaces
end
def roots
- return super unless use_traversal_ids_roots?
+ return super unless use_traversal_ids?
root_ids = all.select("#{quoted_table_name}.traversal_ids[1]").distinct
unscoped.where(id: root_ids)
@@ -37,13 +37,13 @@ module Namespaces
end
def self_and_descendants(include_self: true)
- return super unless use_traversal_ids_for_descendants_scopes?
+ return super unless use_traversal_ids?
self_and_descendants_with_comparison_operators(include_self: include_self)
end
def self_and_descendant_ids(include_self: true)
- return super unless use_traversal_ids_for_descendants_scopes?
+ return super unless use_traversal_ids?
self_and_descendants(include_self: include_self).as_ids
end
@@ -78,16 +78,6 @@ module Namespaces
Feature.enabled?(:use_traversal_ids)
end
- def use_traversal_ids_roots?
- Feature.enabled?(:use_traversal_ids_roots) &&
- use_traversal_ids?
- end
-
- def use_traversal_ids_for_descendants_scopes?
- Feature.enabled?(:use_traversal_ids_for_descendants_scopes) &&
- use_traversal_ids?
- end
-
def use_traversal_ids_for_self_and_hierarchy_scopes?
Feature.enabled?(:use_traversal_ids_for_self_and_hierarchy_scopes) &&
use_traversal_ids?
diff --git a/app/models/note.rb b/app/models/note.rb
index 09ff7ad3979..2df643c46aa 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -26,7 +26,7 @@ class Note < ApplicationRecord
include IgnorableColumns
include Spammable
- ignore_column :id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22'
+ ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22'
ISSUE_TASK_SYSTEM_NOTE_PATTERN = /\A.*marked\sthe\stask.+as\s(completed|incomplete).*\z/.freeze
@@ -756,7 +756,7 @@ class Note < ApplicationRecord
Ability.users_that_can_read_internal_notes(users, resource_parent).pluck(:id)
end
- # Method necesary while we transition into the new format for task system notes
+ # Method necessary while we transition into the new format for task system notes
# TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/369923
def note
return super unless system? && for_issue? && super&.match?(ISSUE_TASK_SYSTEM_NOTE_PATTERN)
@@ -792,6 +792,14 @@ class Note < ApplicationRecord
true
end
+ # Use attributes.keys instead of attribute_names to filter out the fields that are skipped during export:
+ #
+ # - note_html
+ # - cached_markdown_version
+ def attribute_names_for_serialization
+ attributes.keys
+ end
+
private
def trigger_note_subscription?
diff --git a/app/models/organizations/organization.rb b/app/models/organizations/organization.rb
index ce89f57a73b..8aeca2eb137 100644
--- a/app/models/organizations/organization.rb
+++ b/app/models/organizations/organization.rb
@@ -8,6 +8,14 @@ module Organizations
before_destroy :check_if_default_organization
+ has_many :namespaces
+ has_many :groups
+
+ has_one :settings, class_name: "OrganizationSetting"
+
+ has_many :organization_users, inverse_of: :organization
+ has_many :users, through: :organization_users, inverse_of: :organizations
+
validates :name,
presence: true,
length: { maximum: 255 }
diff --git a/app/models/organizations/organization_setting.rb b/app/models/organizations/organization_setting.rb
new file mode 100644
index 00000000000..108531e6701
--- /dev/null
+++ b/app/models/organizations/organization_setting.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Organizations
+ class OrganizationSetting < ApplicationRecord
+ belongs_to :organization
+
+ validates :settings, json_schema: { filename: "organization_settings" }
+
+ jsonb_accessor :settings,
+ restricted_visibility_levels: [:integer, { array: true }]
+
+ validates_each :restricted_visibility_levels do |record, attr, value|
+ value&.each do |level|
+ unless Gitlab::VisibilityLevel.options.value?(level)
+ record.errors.add(attr, format(_("'%{level}' is not a valid visibility level"), level: level))
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/organizations/organization_user.rb b/app/models/organizations/organization_user.rb
new file mode 100644
index 00000000000..5aa1133b017
--- /dev/null
+++ b/app/models/organizations/organization_user.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module Organizations
+ class OrganizationUser < ApplicationRecord
+ belongs_to :organization, inverse_of: :organization_users, optional: false
+ belongs_to :user, inverse_of: :organization_users, optional: false
+ end
+end
diff --git a/app/models/packages/npm/metadatum.rb b/app/models/packages/npm/metadatum.rb
index ccbf056ec7b..2fc1c05cd48 100644
--- a/app/models/packages/npm/metadatum.rb
+++ b/app/models/packages/npm/metadatum.rb
@@ -26,6 +26,11 @@ class Packages::Npm::Metadatum < ApplicationRecord
def ensure_package_json_size
return if package_json.to_s.size < MAX_PACKAGE_JSON_SIZE
- errors.add(:package_json, _('structure is too large'))
+ errors.add(:package_json, :too_large,
+ message: format(
+ _('structure is too large. Maximum size is %{max_size} characters'),
+ max_size: MAX_PACKAGE_JSON_SIZE
+ )
+ )
end
end
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index 58305b45457..b618c7c20c4 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -83,7 +83,7 @@ class Packages::Package < ApplicationRecord
validates :name, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan?
validates :name, format: { with: Gitlab::Regex.generic_package_name_regex }, if: :generic?
validates :name, format: { with: Gitlab::Regex.helm_package_regex }, if: :helm?
- validates :name, format: { with: Gitlab::Regex.npm_package_name_regex }, if: :npm?
+ validates :name, format: { with: Gitlab::Regex.npm_package_name_regex, message: Gitlab::Regex.npm_package_name_regex_message }, if: :npm?
validates :name, format: { with: Gitlab::Regex.nuget_package_name_regex }, if: :nuget?
validates :name, format: { with: Gitlab::Regex.terraform_module_package_name_regex }, if: :terraform_module?
validates :name, format: { with: Gitlab::Regex.debian_package_name_regex }, if: :debian_package?
@@ -94,7 +94,8 @@ class Packages::Package < ApplicationRecord
validates :version, format: { with: Gitlab::Regex.pypi_version_regex }, if: :pypi?
validates :version, format: { with: Gitlab::Regex.prefixed_semver_regex }, if: :golang?
validates :version, format: { with: Gitlab::Regex.helm_version_regex }, if: :helm?
- validates :version, format: { with: Gitlab::Regex.semver_regex }, if: -> { composer_tag_version? || npm? || terraform_module? }
+ validates :version, format: { with: Gitlab::Regex.semver_regex, message: Gitlab::Regex.semver_regex_message },
+ if: -> { composer_tag_version? || npm? || terraform_module? }
validates :version,
presence: true,
@@ -166,16 +167,16 @@ class Packages::Package < ApplicationRecord
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.last_of_each_version_ids) }
- scope :last_of_each_version_ids, -> { select('MAX(id) AS id').unscope(where: :id).group(:version) }
scope :limit_recent, ->(limit) { order_created_desc.limit(limit) }
scope :select_distinct_name, -> { select(:name).distinct }
+ scope :select_only_first_by_name, -> { select('DISTINCT ON (name) *') }
# Sorting
scope :order_created, -> { reorder(created_at: :asc) }
scope :order_created_desc, -> { reorder(created_at: :desc) }
scope :order_name, -> { reorder(name: :asc) }
scope :order_name_desc, -> { reorder(name: :desc) }
+ scope :order_name_desc_version_desc, -> { reorder(name: :desc, version: :desc) }
scope :order_version, -> { reorder(version: :asc) }
scope :order_version_desc, -> { reorder(version: :desc) }
scope :order_type, -> { reorder(package_type: :asc) }
diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb
index 864ea04c019..2ffb2e84cbf 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_namespace_url == project.pages_url
+ if url_builder.namespace_pages?
'/'
else
"#{project.full_path.delete_prefix(trim_prefix)}/"
@@ -55,9 +55,7 @@ module Pages
strong_memoize_attr :prefix
def unique_host
- return unless project.project_setting.pages_unique_domain_enabled?
-
- project.pages_unique_host
+ url_builder.unique_host
end
strong_memoize_attr :unique_host
@@ -76,5 +74,10 @@ module Pages
project.pages_metadatum.pages_deployment
end
strong_memoize_attr :deployment
+
+ def url_builder
+ Gitlab::Pages::UrlBuilder.new(project)
+ end
+ strong_memoize_attr :url_builder
end
end
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index 2749404b7b5..08f725de980 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -20,6 +20,7 @@ class PersonalAccessToken < ApplicationRecord
serialize :scopes, Array # rubocop:disable Cop/ActiveRecordSerialize
belongs_to :user
+ belongs_to :previous_personal_access_token, class_name: 'PersonalAccessToken'
after_initialize :set_default_scopes, if: :persisted?
before_save :ensure_token
@@ -99,9 +100,13 @@ class PersonalAccessToken < ApplicationRecord
def expires_at_before_instance_max_expiry_date
return unless expires_at
- if expires_at > MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now
- errors.add(:expires_at, _('must expire in 365 days'))
- end
+ max_expiry_date = Date.current.advance(days: MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS)
+ return unless expires_at > max_expiry_date
+
+ errors.add(
+ :expires_at,
+ format(_("must be before %{expiry_date}"), expiry_date: max_expiry_date)
+ )
end
end
diff --git a/app/models/plan_limits.rb b/app/models/plan_limits.rb
index 6795e7a3049..245c0719439 100644
--- a/app/models/plan_limits.rb
+++ b/app/models/plan_limits.rb
@@ -2,6 +2,9 @@
class PlanLimits < ApplicationRecord
include IgnorableColumns
+ ALLOWED_LIMITS_HISTORY_ATTRIBUTES = %i[notification_limit enforcement_limit storage_size_limit
+ dashboard_limit_enabled_at].freeze
+
ignore_column :ci_max_artifact_size_running_container_scanning, remove_with: '14.3', remove_after: '2021-08-22'
ignore_column :web_hook_calls_high, remove_with: '15.10', remove_after: '2022-02-22'
ignore_column :ci_active_pipelines, remove_with: '16.3', remove_after: '2022-07-22'
@@ -50,32 +53,23 @@ class PlanLimits < ApplicationRecord
false
end
- def log_limits_changes(user, new_limits)
- new_limits.each do |attribute, value|
+ def format_limits_history(user, new_limits)
+ allowed_limits = new_limits.slice(*ALLOWED_LIMITS_HISTORY_ATTRIBUTES)
+ return {} if allowed_limits.empty?
+
+ allowed_limits.each do |attribute, value|
+ next if value == self[attribute]
+
limits_history[attribute] ||= []
limits_history[attribute] << {
- user_id: user&.id,
- username: user&.username,
- timestamp: Time.current.utc.to_i,
- value: value
+ "user_id" => user.id,
+ "username" => user.username,
+ "timestamp" => Time.current.utc.to_i,
+ "value" => value
}
end
- update(limits_history: limits_history)
- end
-
- def limit_attribute_changes(attribute)
- limit_history = limits_history[attribute]
- return [] unless limit_history
-
- limit_history.map do |entry|
- {
- timestamp: entry[:timestamp],
- value: entry[:value],
- username: entry[:username],
- user_id: entry[:user_id]
- }
- end
+ limits_history
end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index 452a5c8973c..931f4db3a54 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -415,7 +415,7 @@ class Project < ApplicationRecord
has_one :ci_cd_settings, class_name: 'ProjectCiCdSetting', inverse_of: :project, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :remote_mirrors, inverse_of: :project
- has_many :external_pull_requests, inverse_of: :project
+ has_many :external_pull_requests, inverse_of: :project, class_name: 'Ci::ExternalPullRequest'
has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_project_id
has_many :source_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :project_id
@@ -692,6 +692,10 @@ class Project < ApplicationRecord
scope :with_integration, -> (integration_class) { joins(:integrations).merge(integration_class.all) }
scope :with_active_integration, -> (integration_class) { with_integration(integration_class).merge(integration_class.active) }
scope :with_shared_runners_enabled, -> { where(shared_runners_enabled: true) }
+ # .with_slack_integration can generate poorly performing queries. It is intended only for UsagePing.
+ scope :with_slack_integration, -> { joins(:slack_integration) }
+ # .with_slack_slash_commands_integration can generate poorly performing queries. It is intended only for UsagePing.
+ scope :with_slack_slash_commands_integration, -> { joins(:slack_slash_commands_integration) }
scope :inside_path, ->(path) do
# We need routes alias rs for JOIN so it does not conflict with
# includes(:route) which we use in ProjectsFinder.
@@ -775,6 +779,7 @@ class Project < ApplicationRecord
scope :pending_data_repair_analysis, -> do
left_outer_joins(:container_registry_data_repair_detail)
.where(container_registry_data_repair_details: { project_id: nil })
+ .order(id: :desc)
end
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
@@ -904,6 +909,16 @@ class Project < ApplicationRecord
scope :for_group_and_its_ancestor_groups, ->(group) { where(namespace_id: group.self_and_ancestors.select(:id)) }
scope :is_importing, -> { with_import_state.where(import_state: { status: %w[started scheduled] }) }
+ scope :without_created_and_owned_by_banned_user, -> do
+ where_not_exists(
+ Users::BannedUser.joins(
+ 'INNER JOIN project_authorizations ON project_authorizations.user_id = banned_users.user_id'
+ ).where('projects.creator_id = banned_users.user_id')
+ .where('project_authorizations.project_id = projects.id')
+ .where(project_authorizations: { access_level: Gitlab::Access::OWNER })
+ )
+ end
+
class << self
# Searches for a list of projects based on the query given in `query`.
#
@@ -1840,10 +1855,12 @@ class Project < ApplicationRecord
triggered.add_hooks(hooks)
end
- def execute_integrations(data, hooks_scope = :push_hooks)
+ def execute_integrations(data, hooks_scope = :push_hooks, skip_ci: false)
# Call only service hooks that are active for this scope
run_after_commit_or_now do
association("#{hooks_scope}_integrations").reader.each do |integration|
+ next if skip_ci && integration.ci?
+
integration.async_execute(data)
end
end
@@ -2201,42 +2218,6 @@ class Project < ApplicationRecord
pages_metadatum&.deployed?
end
- def pages_url(with_unique_domain: false)
- return pages_unique_url if with_unique_domain && pages_unique_domain_enabled?
-
- 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 == namespace_url
-
- "#{url}/#{url_path}"
- end
-
- def pages_unique_url
- pages_url_for(project_setting.pages_unique_domain)
- end
-
- def pages_unique_host
- URI(pages_unique_url).host
- end
-
- def pages_namespace_url
- pages_url_for(pages_subdomain)
- end
-
- def pages_subdomain
- full_path.partition('/').first
- end
-
def pages_path
# TODO: when we migrate Pages to work with new storage types, change here to use disk_path
File.join(Settings.pages.path, full_path)
@@ -2483,7 +2464,7 @@ class Project < ApplicationRecord
break unless pages_enabled?
variables.append(key: 'CI_PAGES_DOMAIN', value: Gitlab.config.pages.host)
- variables.append(key: 'CI_PAGES_URL', value: pages_url)
+ variables.append(key: 'CI_PAGES_URL', value: Gitlab::Pages::UrlBuilder.new(self).pages_url)
end
end
@@ -3167,6 +3148,10 @@ class Project < ApplicationRecord
pending_delete? || hidden?
end
+ def created_and_owned_by_banned_user?
+ creator.banned? && team.max_member_access(creator.id) == Gitlab::Access::OWNER
+ end
+
def content_editor_on_issues_feature_flag_enabled?
group&.content_editor_on_issues_feature_flag_enabled? || Feature.enabled?(:content_editor_on_issues, self)
end
@@ -3236,25 +3221,8 @@ class Project < ApplicationRecord
group.crm_enabled?
end
- def frozen_outbound_job_token_scopes?
- Feature.enabled?(:frozen_outbound_job_token_scopes, self) && Feature.disabled?(:frozen_outbound_job_token_scopes_override, self)
- end
- strong_memoize_attr :frozen_outbound_job_token_scopes?
-
private
- def pages_unique_domain_enabled?
- Feature.enabled?(:pages_unique_domain, self) &&
- project_setting.pages_unique_domain_enabled?
- end
-
- def pages_url_for(domain)
- # The host in URL always needs to be downcased
- Gitlab.config.pages.url.sub(%r{^https?://}) do |prefix|
- "#{prefix}#{domain}."
- end.downcase
- end
-
# overridden in EE
def project_group_links_with_preload
project_group_links
diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb
index aa65f27870d..cc9003423be 100644
--- a/app/models/project_ci_cd_setting.rb
+++ b/app/models/project_ci_cd_setting.rb
@@ -2,7 +2,6 @@
class ProjectCiCdSetting < ApplicationRecord
include ChronicDurationAttribute
- include IgnorableColumns
belongs_to :project, inverse_of: :ci_cd_settings
@@ -23,8 +22,6 @@ class ProjectCiCdSetting < ApplicationRecord
chronic_duration_attr :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval
- ignore_column :opt_in_jwt, remove_with: '16.2', remove_after: '2023-07-01'
-
def keep_latest_artifacts_available?
# The project level feature can only be enabled when the feature is enabled instance wide
Gitlab::CurrentSettings.current_application_settings.keep_latest_artifact? && keep_latest_artifact?
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index 14f6a90e5ed..365bb5237c3 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -34,7 +34,6 @@ class ProjectStatistics < ApplicationRecord
:build_artifacts_size,
:packages_size,
:snippets_size,
- :pipeline_artifacts_size,
:uploads_size
].freeze
diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb
index ed1795b43e0..347d65841ed 100644
--- a/app/models/projects/topic.rb
+++ b/app/models/projects/topic.rb
@@ -71,7 +71,7 @@ module Projects
# /\R/ - A linebreak: \n, \v, \f, \r \u0085 (NEXT LINE),
# \u2028 (LINE SEPARATOR), \u2029 (PARAGRAPH SEPARATOR) or \r\n.
- return unless name =~ /\R/
+ return unless /\R/.match?(name)
errors.add(:name, 'has characters that are not allowed')
end
diff --git a/app/models/projects/triggered_hooks.rb b/app/models/projects/triggered_hooks.rb
index e3aa3d106b7..1f51ced5b57 100644
--- a/app/models/projects/triggered_hooks.rb
+++ b/app/models/projects/triggered_hooks.rb
@@ -17,6 +17,8 @@ module Projects
# Assumes that the relations implement TriggerableHooks
@relations.each do |hooks|
hooks.hooks_for(@scope).select_active(@scope, @data).each do |hook|
+ next if @scope == :emoji_hooks && Feature.disabled?(:emoji_webhooks, hook.parent)
+
hook.async_execute(@data, @scope.to_s)
end
end
diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb
index c86ca5723fa..53cec0c5511 100644
--- a/app/models/protected_branch/push_access_level.rb
+++ b/app/models/protected_branch/push_access_level.rb
@@ -3,49 +3,7 @@
class ProtectedBranch::PushAccessLevel < ApplicationRecord
include Importable
include ProtectedBranchAccess
+ include ProtectedRefDeployKeyAccess
# default value for the access_level column
GITLAB_DEFAULT_ACCESS_LEVEL = Gitlab::Access::MAINTAINER
-
- belongs_to :deploy_key
-
- validates :access_level, uniqueness: { scope: :protected_branch_id, if: :role?,
- conditions: -> { where(user_id: nil, group_id: nil, deploy_key_id: nil) } }
- validates :deploy_key_id, uniqueness: { scope: :protected_branch_id, allow_nil: true }
- validate :validate_deploy_key_membership
-
- def type
- if self.deploy_key.present?
- :deploy_key
- else
- super
- end
- end
-
- def humanize
- return "Deploy key" if deploy_key.present?
-
- super
- end
-
- def check_access(user)
- if user && deploy_key.present?
- return user.can?(:read_project, project) && enabled_deploy_key_for_user?(deploy_key, user)
- end
-
- super
- end
-
- private
-
- def validate_deploy_key_membership
- return unless deploy_key
-
- unless project.deploy_keys_projects.where(deploy_key: deploy_key).exists?
- self.errors.add(:deploy_key, 'is not enabled for this project')
- end
- end
-
- def enabled_deploy_key_for_user?(deploy_key, user)
- deploy_key.user_id == user.id && DeployKey.with_write_access_for_project(protected_branch.project, deploy_key: deploy_key).any?
- end
end
diff --git a/app/models/protected_tag/create_access_level.rb b/app/models/protected_tag/create_access_level.rb
index 5837f3a5afb..0eff9924153 100644
--- a/app/models/protected_tag/create_access_level.rb
+++ b/app/models/protected_tag/create_access_level.rb
@@ -3,48 +3,5 @@
class ProtectedTag::CreateAccessLevel < ApplicationRecord
include Importable
include ProtectedTagAccess
-
- belongs_to :deploy_key
-
- validates :access_level, uniqueness: { scope: :protected_tag_id, if: :role?,
- conditions: -> { where(user_id: nil, group_id: nil, deploy_key_id: nil) } }
- validates :deploy_key_id, uniqueness: { scope: :protected_tag_id, allow_nil: true }
- validate :validate_deploy_key_membership
-
- def type
- return :deploy_key if deploy_key.present?
-
- super
- end
-
- def humanize
- return "Deploy key" if deploy_key.present?
-
- super
- end
-
- def check_access(current_user)
- super do
- break enabled_deploy_key_for_user?(current_user) if deploy_key?
- end
- end
-
- private
-
- def deploy_key?
- type == :deploy_key
- end
-
- def validate_deploy_key_membership
- return unless deploy_key
- return if project.deploy_keys_projects.where(deploy_key: deploy_key).exists?
-
- errors.add(:deploy_key, 'is not enabled for this project')
- end
-
- def enabled_deploy_key_for_user?(current_user)
- current_user.can?(:read_project, project) &&
- deploy_key.user_id == current_user.id &&
- DeployKey.with_write_access_for_project(protected_tag.project, deploy_key: deploy_key).any?
- end
+ include ProtectedRefDeployKeyAccess
end
diff --git a/app/models/release.rb b/app/models/release.rb
index 7f74872cf67..f0ba56390ab 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -64,10 +64,10 @@ class Release < ApplicationRecord
end
# This query uses LATERAL JOIN to find the latest release for each project. To avoid
- # joining the `releases` table, we build an in-memory table using the project ids.
+ # joining the `projects` table, we build an in-memory table using the project ids.
# Example:
# SELECT ...
- # FROM (VALUES (PROJECT_ID_1),(PROJECT_ID_2)) project_ids (id)
+ # FROM (VALUES (PROJECT_ID_1),(PROJECT_ID_2)) projects (id)
# INNER JOIN LATERAL (...)
def latest_for_projects(projects, order_by: 'released_at')
return Release.none if projects.empty?
diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb
index 8b2f3bdcedf..934053cb92d 100644
--- a/app/models/remote_mirror.rb
+++ b/app/models/remote_mirror.rb
@@ -137,6 +137,7 @@ class RemoteMirror < ApplicationRecord
return false unless project.remote_mirror_available?
return false unless project.repository_exists?
return false if project.pending_delete?
+ return false if Gitlab::SilentMode.enabled?
true
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index b21df6baf0e..1321c9da780 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -838,7 +838,7 @@ class Repository
files = ls_files(options[:branch_name])
options[:actions] = files.each_with_object([]) do |item, list|
- next unless item =~ regex
+ next unless regex.match?(item)
list.push(
action: :move,
diff --git a/app/models/service_desk_setting.rb b/app/models/service_desk_setting.rb
index 4216ad7e70f..6560b25b39c 100644
--- a/app/models/service_desk_setting.rb
+++ b/app/models/service_desk_setting.rb
@@ -21,6 +21,7 @@ class ServiceDeskSetting < ApplicationRecord
validates :project_id, presence: true
validate :valid_issue_template
validate :valid_project_key
+ validate :custom_email_enabled_state
validates :outgoing_name, length: { maximum: 255 }, allow_blank: true
validates :project_key,
length: { maximum: 255 },
@@ -86,6 +87,14 @@ class ServiceDeskSetting < ApplicationRecord
end
end
+ def custom_email_enabled_state
+ return unless custom_email_enabled?
+
+ if custom_email_verification.blank? || !custom_email_verification.finished?
+ errors.add(:custom_email_enabled, 'cannot be enabled until verification process has finished.')
+ end
+ end
+
private
def source_template_project
diff --git a/app/models/system_access.rb b/app/models/system_access.rb
new file mode 100644
index 00000000000..9ffc63c5ca8
--- /dev/null
+++ b/app/models/system_access.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module SystemAccess
+ def self.table_name_prefix
+ 'system_access_'
+ end
+end
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 724f97c4812..f202e1a266d 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -117,7 +117,18 @@ class Todo < ApplicationRecord
# target - The value of the `target_type` column, such as `Issue`.
# state - The value of the `state` column, such as `pending` or `done`.
def any_for_target?(target, state = nil)
- state.nil? ? exists?(target: target) : exists?(target: target, state: state)
+ conditions = {}
+
+ if target.respond_to?(:todoable_target_type_name)
+ conditions[:target_type] = target.todoable_target_type_name
+ conditions[:target_id] = target.id
+ else
+ conditions[:target] = target
+ end
+
+ conditions[:state] = state unless state.nil?
+
+ exists?(conditions)
end
# Updates attributes of a relation of todos to the new state.
diff --git a/app/models/user.rb b/app/models/user.rb
index 96cdbb192bc..4a57cc2e2e2 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -60,7 +60,7 @@ class User < ApplicationRecord
INCOMING_MAIL_TOKEN_PREFIX = 'glimt-'
FEED_TOKEN_PREFIX = 'glft-'
- columns_changing_default :notified_of_own_activity
+ columns_changing_default :project_view
# lib/tasks/tokens.rake needs to be updated when changing mail and feed tokens
add_authentication_token_field :incoming_email_token, token_generator: -> { self.generate_incoming_mail_token }
@@ -170,8 +170,11 @@ class User < ApplicationRecord
has_many :following_users, foreign_key: :followee_id, class_name: 'Users::UserFollowUser'
has_many :followers, through: :following_users
- # Groups
+ # Namespaces
has_many :members
+ has_many :member_namespaces, through: :members
+
+ # Groups
has_many :group_members, -> { where(requested_at: nil).where("access_level >= ?", Gitlab::Access::GUEST) }, class_name: 'GroupMember'
has_many :groups, through: :group_members
has_many :groups_with_active_memberships, -> { where(members: { state: ::Member::STATE_ACTIVE }) }, through: :group_members, source: :group
@@ -256,6 +259,9 @@ class User < ApplicationRecord
has_many :term_agreements
belongs_to :accepted_term, class_name: 'ApplicationSetting::Term'
+ has_many :organization_users, class_name: 'Organizations::OrganizationUser', inverse_of: :user
+ has_many :organizations, through: :organization_users, class_name: 'Organizations::Organization', inverse_of: :users
+
has_many :metrics_users_starred_dashboards, class_name: 'Metrics::UsersStarredDashboard', inverse_of: :user
has_one :status, class_name: 'UserStatus'
@@ -1541,7 +1547,7 @@ class User < ApplicationRecord
end
def full_website_url
- return "http://#{website_url}" if website_url !~ %r{\Ahttps?://}
+ return "http://#{website_url}" unless %r{\Ahttps?://}.match?(website_url)
website_url
end
@@ -1827,8 +1833,12 @@ class User < ApplicationRecord
Project.where(id: events).not_aimed_for_deletion
end
+ # Returns true if the user can be removed, false otherwise.
+ # A user can be removed if they do not own any groups where they are the sole owner
+ # Method `none?` is used to ensure faster retrieval, See https://gitlab.com/gitlab-org/gitlab/-/issues/417105
+
def can_be_removed?
- !solo_owned_groups.present?
+ solo_owned_groups.none?
end
def can_remove_self?
@@ -2063,9 +2073,17 @@ class User < ApplicationRecord
# override, from Devise
def lock_access!(opts = {})
Gitlab::AppLogger.info("Account Locked: username=#{username}")
+ audit_lock_access(reason: opts.delete(:reason))
super
end
+ # override, from Devise
+ def unlock_access!(unlocked_by: self)
+ audit_unlock_access(author: unlocked_by)
+
+ super()
+ end
+
# Determine the maximum access level for a group of projects in bulk.
#
# Returns a Hash mapping project ID -> maximum access level.
@@ -2103,7 +2121,7 @@ class User < ApplicationRecord
end
def terms_accepted?
- return true if project_bot?
+ return true if project_bot? || service_account? || security_policy_bot?
accepted_term_id.present?
end
@@ -2279,30 +2297,6 @@ class User < ApplicationRecord
namespace_commit_emails.find_by(namespace: project.root_namespace)
end
- def spammer?
- spam_score > Abuse::TrustScore::SPAMCHECK_HAM_THRESHOLD
- end
-
- def spam_score
- abuse_trust_scores.spamcheck.average(:score) || 0.0
- end
-
- def telesign_score
- abuse_trust_scores.telesign.order(created_at: :desc).first&.score || 0.0
- end
-
- def arkose_global_score
- abuse_trust_scores.arkose_global_score.order(created_at: :desc).first&.score || 0.0
- end
-
- def arkose_custom_score
- abuse_trust_scores.arkose_custom_score.order(created_at: :desc).first&.score || 0.0
- end
-
- def trust_scores_for_source(source)
- abuse_trust_scores.where(source: source)
- end
-
def abuse_metadata
{
account_age: account_age_in_days,
@@ -2310,6 +2304,10 @@ class User < ApplicationRecord
}
end
+ def allow_possible_spam?
+ custom_attributes.by_key(UserCustomAttribute::ALLOW_POSSIBLE_SPAM).exists?
+ end
+
def namespace_commit_email_for_namespace(namespace)
return if namespace.nil?
@@ -2330,7 +2328,7 @@ class User < ApplicationRecord
return super if ::Gitlab::CurrentSettings.email_confirmation_setting_soft?
# Following devise logic for method, we want to return `true`
- # See: https://github.com/heartcombo/devise/blob/main/lib/devise/models/confirmable.rb#L191-L218
+ # See: https://github.com/heartcombo/devise/blob/ec0674523e7909579a5a008f16fb9fe0c3a71712/lib/devise/models/confirmable.rb#L191-L218
true
end
alias_method :in_confirmation_period?, :confirmation_period_valid?
@@ -2355,7 +2353,8 @@ class User < ApplicationRecord
private
def block_or_ban
- if spammer? && account_age_in_days < 7
+ user_scores = Abuse::UserTrustScore.new(self)
+ if user_scores.spammer? && account_age_in_days < 7
ban_and_report
else
block
@@ -2608,6 +2607,12 @@ class User < ApplicationRecord
def prefix_for_feed_token
FEED_TOKEN_PREFIX
end
+
+ # method overriden in EE
+ def audit_lock_access(reason: nil); end
+
+ # method overriden in EE
+ def audit_unlock_access(author: self); end
end
User.prepend_mod_with('User')
diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb
index 63a5ee9770f..425f2cc062b 100644
--- a/app/models/user_custom_attribute.rb
+++ b/app/models/user_custom_attribute.rb
@@ -15,6 +15,8 @@ class UserCustomAttribute < ApplicationRecord
UNBLOCKED_BY = 'unblocked_by'
ARKOSE_RISK_BAND = 'arkose_risk_band'
AUTO_BANNED_BY_ABUSE_REPORT_ID = 'auto_banned_by_abuse_report_id'
+ ALLOW_POSSIBLE_SPAM = 'allow_possible_spam'
+ IDENTITY_VERIFICATION_PHONE_EXEMPT = 'identity_verification_phone_exempt'
class << self
def upsert_custom_attributes(custom_attributes)
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index 4d517408154..c263d552d40 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -2,15 +2,12 @@
class UserPreference < ApplicationRecord
include IgnorableColumns
- include SafelyChangeColumnDefault
# We could use enums, but Rails 4 doesn't support multiple
# enum options with same name for multiple fields, also it creates
# extra methods that aren't really needed here.
NOTES_FILTERS = { all_notes: 0, only_comments: 1, only_activity: 2 }.freeze
- columns_changing_default :tab_width, :time_display_relative, :render_whitespace_in_code
-
belongs_to :user
scope :with_user, -> { joins(:user) }
@@ -31,7 +28,6 @@ class UserPreference < ApplicationRecord
validates :pinned_nav_items, json_schema: { filename: 'pinned_nav_items' }
ignore_columns :experience_level, remove_with: '14.10', remove_after: '2021-03-22'
- ignore_columns :time_format_in_24h, remove_with: '16.2', remove_after: '2023-07-22'
# 2023-06-22 is after 16.1 release and during 16.2 release https://docs.gitlab.com/ee/development/database/avoiding_downtime_in_migrations.html#ignoring-the-column-release-m
ignore_columns :use_legacy_web_ide, remove_with: '16.2', remove_after: '2023-06-22'
diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb
index 38e518b6d3e..0d02a3b99aa 100644
--- a/app/models/users/callout.rb
+++ b/app/models/users/callout.rb
@@ -55,10 +55,10 @@ module Users
submit_license_usage_data_banner: 52, # EE-only
personal_project_limitations_banner: 53, # EE-only
mr_experience_survey: 54,
- namespace_storage_limit_banner_info_threshold: 55, # EE-only
- namespace_storage_limit_banner_warning_threshold: 56, # EE-only
- namespace_storage_limit_banner_alert_threshold: 57, # EE-only
- namespace_storage_limit_banner_error_threshold: 58, # EE-only
+ # 55 removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121920
+ namespace_storage_limit_alert_warning_threshold: 56, # EE-only
+ namespace_storage_limit_alert_alert_threshold: 57, # EE-only
+ namespace_storage_limit_alert_error_threshold: 58, # EE-only
project_quality_summary_feedback: 59, # EE-only
merge_request_settings_moved_callout: 60,
new_top_level_group_alert: 61,
@@ -66,13 +66,14 @@ module Users
# 63 and 64 were removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/120233
branch_rules_info_callout: 65,
create_runner_workflow_banner: 66,
- repository_storage_limit_banner_info_threshold: 67, # EE-only
- repository_storage_limit_banner_warning_threshold: 68, # EE-only
- repository_storage_limit_banner_alert_threshold: 69, # EE-only
- repository_storage_limit_banner_error_threshold: 70, # EE-only
+ # 67 removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121920
+ project_repository_limit_alert_warning_threshold: 68, # EE-only
+ project_repository_limit_alert_alert_threshold: 69, # EE-only
+ project_repository_limit_alert_error_threshold: 70, # EE-only
new_navigation_callout: 71,
code_suggestions_third_party_callout: 72, # EE-only
- namespace_over_storage_users_combined_alert: 73 # EE-only
+ namespace_over_storage_users_combined_alert: 73, # EE-only
+ rich_text_editor: 74
}
validates :feature_name,
diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb
index c5946197b6f..74b653b5777 100644
--- a/app/models/users/group_callout.rb
+++ b/app/models/users/group_callout.rb
@@ -17,19 +17,19 @@ module Users
preview_user_over_limit_free_plan_alert: 7, # EE-only
user_reached_limit_free_plan_alert: 8, # EE-only
free_group_limited_alert: 9, # EE-only
- namespace_storage_limit_banner_info_threshold: 10, # EE-only
- namespace_storage_limit_banner_warning_threshold: 11, # EE-only
- namespace_storage_limit_banner_alert_threshold: 12, # EE-only
- namespace_storage_limit_banner_error_threshold: 13, # EE-only
+ # 10 removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121920
+ namespace_storage_limit_alert_warning_threshold: 11, # EE-only
+ namespace_storage_limit_alert_alert_threshold: 12, # EE-only
+ namespace_storage_limit_alert_error_threshold: 13, # EE-only
usage_quota_trial_alert: 14, # EE-only
preview_usage_quota_free_plan_alert: 15, # EE-only
enforcement_at_limit_alert: 16, # EE-only
web_hook_disabled: 17, # EE-only
unlimited_members_during_trial_alert: 18, # EE-only
- repository_storage_limit_banner_info_threshold: 19, # EE-only
- repository_storage_limit_banner_warning_threshold: 20, # EE-only
- repository_storage_limit_banner_alert_threshold: 21, # EE-only
- repository_storage_limit_banner_error_threshold: 22, # EE-only
+ # 19 removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121920
+ project_repository_limit_alert_warning_threshold: 20, # EE-only
+ project_repository_limit_alert_alert_threshold: 21, # EE-only
+ project_repository_limit_alert_error_threshold: 22, # EE-only
namespace_over_storage_users_combined_alert: 23 # EE-only
}
diff --git a/app/models/webauthn_registration.rb b/app/models/webauthn_registration.rb
index c8b2513e702..5480b9e9c4a 100644
--- a/app/models/webauthn_registration.rb
+++ b/app/models/webauthn_registration.rb
@@ -3,10 +3,6 @@
# Registration information for WebAuthn credentials
class WebauthnRegistration < ApplicationRecord
- include IgnorableColumns
-
- ignore_column :u2f_registration_id, remove_with: '16.2', remove_after: '2023-06-22'
-
belongs_to :user
validates :credential_xid, :public_key, :counter, presence: true
diff --git a/app/models/work_item.rb b/app/models/work_item.rb
index 9f28ffbf7b6..adf424a1d94 100644
--- a/app/models/work_item.rb
+++ b/app/models/work_item.rb
@@ -65,6 +65,12 @@ class WorkItem < Issue
'issue'
end
+ # Todo: remove method after target_type cleanup
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/416009
+ def todoable_target_type_name
+ %w[Issue WorkItem]
+ end
+
def widgets
strong_memoize(:widgets) do
work_item_type.widgets.map do |widget_class|
@@ -114,7 +120,9 @@ class WorkItem < Issue
.filter { |param_name| common_params.key?(param_name) }
.each do |param_name|
widget_params[widget.api_symbol] ||= {}
- widget_params[widget.api_symbol][param_name] = common_params.delete(param_name)
+ param_value = common_params.delete(param_name)
+
+ widget_params[widget.api_symbol].merge!(widget.process_quick_action_param(param_name, param_value))
end
end
diff --git a/app/models/work_items/widgets/base.rb b/app/models/work_items/widgets/base.rb
index a8b1b3f9a59..c4e87decdbf 100644
--- a/app/models/work_items/widgets/base.rb
+++ b/app/models/work_items/widgets/base.rb
@@ -15,6 +15,10 @@ module WorkItems
[]
end
+ def self.process_quick_action_param(param_name, value)
+ { param_name => value }
+ end
+
def self.callback_class
WorkItems::Callbacks.const_get(name.demodulize, false)
rescue NameError
diff --git a/app/models/work_items/widgets/current_user_todos.rb b/app/models/work_items/widgets/current_user_todos.rb
index 61c4fcb453b..64297b433dd 100644
--- a/app/models/work_items/widgets/current_user_todos.rb
+++ b/app/models/work_items/widgets/current_user_todos.rb
@@ -3,6 +3,19 @@
module WorkItems
module Widgets
class CurrentUserTodos < Base
+ def self.quick_action_commands
+ [:todo, :done]
+ end
+
+ def self.quick_action_params
+ [:todo_event]
+ end
+
+ def self.process_quick_action_param(param_name, value)
+ return super unless param_name == :todo_event
+
+ { action: value == 'done' ? 'mark_as_done' : 'add' }
+ end
end
end
end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index b96ad9a73c8..bf7bfe36254 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -22,10 +22,6 @@ class GlobalPolicy < BasePolicy
condition(:project_bot, scope: :user) { @user&.project_bot? }
condition(:migration_bot, scope: :user) { @user&.migration_bot? }
- condition(:create_runner_workflow_enabled, scope: :user) do
- Feature.enabled?(:create_runner_workflow_for_admin, @user)
- end
-
condition(:service_account, scope: :user) { @user&.service_account? }
rule { anonymous }.policy do
@@ -128,10 +124,6 @@ class GlobalPolicy < BasePolicy
enable :create_instance_runner
end
- rule { ~create_runner_workflow_enabled }.policy do
- prevent :create_instance_runner
- end
-
# We can't use `read_statistics` because the user may have different permissions for different projects
rule { admin }.enable :use_project_statistics_filters
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 94a67f5b5c8..29b966b43e2 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -97,10 +97,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
with_scope :subject
condition(:crm_enabled, score: 0, scope: :subject) { @subject.crm_enabled? }
- condition(:create_runner_workflow_enabled) do
- Feature.enabled?(:create_runner_workflow_for_namespace, group)
- end
-
condition(:achievements_enabled, scope: :subject) do
Feature.enabled?(:achievements, @subject)
end
@@ -375,10 +371,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :admin_observability
end
- rule { ~create_runner_workflow_enabled }.policy do
- prevent :create_runner
- end
-
# Should be matched with ProjectPolicy#read_internal_note
rule { admin | reporter }.enable :read_internal_note
diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb
index 49f9225a1d3..090be645b21 100644
--- a/app/policies/merge_request_policy.rb
+++ b/app/policies/merge_request_policy.rb
@@ -16,6 +16,10 @@ class MergeRequestPolicy < IssuablePolicy
prevent :accept_merge_request
end
+ rule { can?(:read_merge_request) }.policy do
+ enable :generate_diff_summary
+ end
+
rule { can_approve }.policy do
enable :approve_merge_request
end
@@ -43,6 +47,10 @@ class MergeRequestPolicy < IssuablePolicy
enable :set_merge_request_metadata
end
+ rule { llm_bot }.policy do
+ enable :generate_diff_summary
+ end
+
private
def can_approve?
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index c70dc288710..ad6155258ab 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -253,12 +253,12 @@ class ProjectPolicy < BasePolicy
!Gitlab.config.terraform_state.enabled
end
- condition(:create_runner_workflow_enabled) do
- Feature.enabled?(:create_runner_workflow_for_namespace, project.namespace)
- end
-
condition(:namespace_catalog_available) { namespace_catalog_available? }
+ condition(:created_and_owned_by_banned_user, scope: :subject) do
+ Feature.enabled?(:hide_projects_of_banned_users) && @subject.created_and_owned_by_banned_user?
+ end
+
# `:read_project` may be prevented in EE, but `:read_project_for_iids` should
# not.
rule { guest | admin }.enable :read_project_for_iids
@@ -886,10 +886,6 @@ class ProjectPolicy < BasePolicy
enable :read_code
end
- rule { ~create_runner_workflow_enabled }.policy do
- prevent :create_runner
- end
-
# Should be matched with GroupPolicy#read_internal_note
rule { admin | can?(:reporter_access) }.enable :read_internal_note
@@ -909,6 +905,14 @@ class ProjectPolicy < BasePolicy
enable :read_model_experiments
end
+ rule { can?(:reporter_access) & model_experiments_enabled }.policy do
+ enable :write_model_experiments
+ end
+
+ rule { ~admin & created_and_owned_by_banned_user }.policy do
+ prevent :read_project
+ end
+
private
def user_is_user?
diff --git a/app/presenters/alert_management/alert_presenter.rb b/app/presenters/alert_management/alert_presenter.rb
index 659e991e9d8..60fa351b449 100644
--- a/app/presenters/alert_management/alert_presenter.rb
+++ b/app/presenters/alert_management/alert_presenter.rb
@@ -10,7 +10,7 @@ module AlertManagement
MARKDOWN_LINE_BREAK = " \n"
HORIZONTAL_LINE = "\n\n---\n\n"
- delegate :metrics_dashboard_url, :runbook, to: :parsed_payload
+ delegate :runbook, to: :parsed_payload
def initialize(alert, **attributes)
super
@@ -44,22 +44,10 @@ module AlertManagement
project.incident_management_setting&.create_issue?
end
- def show_performance_dashboard_link?
- prometheus_alert.present?
- end
-
def incident_issues_link
project_incidents_url(project)
end
- def performance_dashboard_link
- if environment
- metrics_project_environment_url(project, environment)
- else
- metrics_project_environments_url(project)
- end
- end
-
def email_title
[environment&.name, query_title].compact.join(': ')
end
@@ -72,8 +60,7 @@ module AlertManagement
def issue_summary_markdown
<<~MARKDOWN.chomp
- #{metadata_list}
- #{metric_embed_for_alert}
+ #{metadata_list}\n
MARKDOWN
end
@@ -92,10 +79,6 @@ module AlertManagement
metadata.join(MARKDOWN_LINE_BREAK)
end
- def metric_embed_for_alert
- "\n[](#{metrics_dashboard_url})" if metrics_dashboard_url
- end
-
def list_item(key, value)
"**#{key}:** #{value}".strip
end
diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb
index cd473152b41..bc12d210334 100644
--- a/app/presenters/blob_presenter.rb
+++ b/app/presenters/blob_presenter.rb
@@ -86,7 +86,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
end
def find_file_path
- url_helpers.project_find_file_path(project, blob.commit_id)
+ url_helpers.project_find_file_path(project, commit_id, ref_type: ref_type)
end
def blame_path
@@ -131,13 +131,13 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
end
def can_modify_blob?
- super(blob, project, blob.commit_id)
+ super(blob, project, commit_id)
end
def can_current_user_push_to_branch?
- return false unless current_user && project.repository.branch_exists?(blob.commit_id)
+ return false unless current_user && project.repository.branch_exists?(commit_id)
- user_access(project).can_push_to_branch?(blob.commit_id)
+ user_access(project).can_push_to_branch?(commit_id)
end
def archived?
@@ -145,7 +145,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
end
def ide_edit_path
- super(project, blob.commit_id, blob.path)
+ super(project, commit_id, blob.path)
end
def external_storage_url
@@ -159,7 +159,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
end
def project_blob_path_root
- project_blob_path(project, blob.commit_id)
+ project_blob_path(project, commit_id)
end
private
@@ -181,7 +181,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
end
def environment
- environment_params = project.repository.branch_exists?(blob.commit_id) ? { ref: blob.commit_id } : { sha: blob.commit_id }
+ environment_params = project.repository.branch_exists?(commit_id) ? { ref: commit_id } : { sha: commit_id }
environment_params[:find_latest] = true
::Environments::EnvironmentsByDeploymentsFinder.new(project, current_user, environment_params).execute.last
end
@@ -190,12 +190,13 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
blob.repository.project
end
- def ref_qualified_path
+ def commit_id
# If `ref_type` is present the commit_id will include the ref qualifier e.g. `refs/heads/`.
# We only accept/return unqualified refs so we need to remove the qualifier from the `commit_id`.
+ ExtractsRef.unqualify_ref(blob.commit_id, ref_type)
+ end
- commit_id = ExtractsRef.unqualify_ref(blob.commit_id, ref_type)
-
+ def ref_qualified_path
File.join(commit_id, blob.path)
end
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index 3aba5a2c7ed..762ee0d92cd 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -65,28 +65,6 @@ module Ci
'%.2f' % pipeline.coverage
end
- def ref_text_legacy
- if pipeline.detached_merge_request_pipeline?
- _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch}")
- .html_safe % {
- link_to_merge_request: link_to_merge_request,
- link_to_merge_request_source_branch: link_to_merge_request_source_branch
- }
- elsif pipeline.merged_result_pipeline?
- _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch} into %{link_to_merge_request_target_branch}")
- .html_safe % {
- link_to_merge_request: link_to_merge_request,
- link_to_merge_request_source_branch: link_to_merge_request_source_branch,
- link_to_merge_request_target_branch: link_to_merge_request_target_branch
- }
- elsif pipeline.ref && pipeline.ref_exists?
- _("for %{link_to_pipeline_ref}")
- .html_safe % { link_to_pipeline_ref: link_to_pipeline_ref }
- elsif pipeline.ref
- _("for %{ref}").html_safe % { ref: plain_ref_name }
- end
- end
-
def ref_text
if pipeline.detached_merge_request_pipeline?
_("Related merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch}")
@@ -109,22 +87,6 @@ module Ci
end
end
- def all_related_merge_request_text(limit: nil)
- if all_related_merge_requests.none?
- _("No related merge requests found.")
- else
- (_("%{count} related %{pluralized_subject}: %{links}") % {
- count: all_related_merge_requests.count,
- pluralized_subject: n_('merge request', 'merge requests', all_related_merge_requests.count),
- links: all_related_merge_request_links(limit: limit).join(', ')
- }).html_safe
- end
- end
-
- def has_many_merge_requests?
- all_related_merge_requests.count > 1
- end
-
def link_to_pipeline_ref
ApplicationController.helpers.link_to(pipeline.ref,
project_commits_path(pipeline.project, pipeline.ref),
diff --git a/app/presenters/ml/models_index_presenter.rb b/app/presenters/ml/models_index_presenter.rb
new file mode 100644
index 00000000000..e2cb8e2d6c1
--- /dev/null
+++ b/app/presenters/ml/models_index_presenter.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Ml
+ class ModelsIndexPresenter
+ def initialize(models)
+ @models = models
+ end
+
+ def present
+ data = @models.map do |m|
+ {
+ name: m.name,
+ version: m.version,
+ path: Gitlab::Routing.url_helpers.project_package_path(m.project, m)
+ }
+ end
+
+ Gitlab::Json.generate({ models: data })
+ end
+ end
+end
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 856eba5aadc..4533ef3633d 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -28,6 +28,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
[
commits_anchor_data,
branches_anchor_data,
+ terraform_states_anchor_data,
tags_anchor_data,
storage_anchor_data,
releases_anchor_data,
@@ -236,6 +237,21 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
)
end
+ def terraform_states_anchor_data
+ if project.terraform_states.exists? && can_read_terraform_state?
+ AnchorData.new(
+ true,
+ statistic_icon('terraform') +
+ n_('%{strong_start}%{terraform_states_count}%{strong_end} Terraform State', '%{strong_start}%{terraform_states_count}%{strong_end} Terraform States', project.terraform_states.count).html_safe % {
+ terraform_states_count: number_with_delimiter(project.terraform_states.count),
+ strong_start: '<strong class="project-stat-value">'.html_safe,
+ strong_end: '</strong>'.html_safe
+ },
+ project_terraform_index_path(project)
+ )
+ end
+ end
+
def tags_anchor_data
AnchorData.new(
true,
@@ -488,6 +504,10 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
end
+ def can_read_terraform_state?
+ current_user && can?(current_user, :read_terraform_state, project)
+ end
+
# Avoid including ActionView::Helpers::UrlHelper
def content_tag(...)
ActionController::Base.helpers.content_tag(...)
diff --git a/app/serializers/diff_viewer_entity.rb b/app/serializers/diff_viewer_entity.rb
index 8ff9d9612c6..f8d9778a3ee 100644
--- a/app/serializers/diff_viewer_entity.rb
+++ b/app/serializers/diff_viewer_entity.rb
@@ -5,7 +5,7 @@ class DiffViewerEntity < Grape::Entity
expose :render_error, as: :error
expose :render_error_message, as: :error_message
expose :collapsed?, as: :collapsed
- expose :whitespace_only, if: ->(_, _) { Feature.enabled?(:add_ignore_all_white_spaces) } do |_, options|
+ expose :whitespace_only do |_, options|
options[:whitespace_only]
end
end
diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb
index 6457127d831..0a3bf4c2a7b 100644
--- a/app/serializers/environment_entity.rb
+++ b/app/serializers/environment_entity.rb
@@ -27,10 +27,6 @@ class EnvironmentEntity < Grape::Entity
ops.merge(except: UNNECESSARY_ENTRIES_FOR_UPCOMING_DEPLOYMENT))
end
- expose :metrics_path, if: -> (*) { expose_metrics_path? } do |environment|
- metrics_project_environment_path(environment.project, environment)
- end
-
expose :environment_path do |environment|
project_environment_path(environment.project, environment)
end
@@ -101,10 +97,6 @@ class EnvironmentEntity < Grape::Entity
def cluster
deployment_platform.cluster
end
-
- def expose_metrics_path?
- !Feature.enabled?(:remove_monitor_metrics) && environment.has_metrics?
- end
end
EnvironmentEntity.prepend_mod_with('EnvironmentEntity')
diff --git a/app/serializers/environment_status_entity.rb b/app/serializers/environment_status_entity.rb
index 9318e0c1de8..8865c030d94 100644
--- a/app/serializers/environment_status_entity.rb
+++ b/app/serializers/environment_status_entity.rb
@@ -15,10 +15,6 @@ class EnvironmentStatusEntity < Grape::Entity
metrics_project_environment_deployment_path(es.project, es.environment, es.deployment)
end
- expose :metrics_monitoring_url, if: ->(*) { can_read_environment? } do |es|
- project_metrics_dashboard_path(es.project, environment: es.environment)
- end
-
expose :stop_url, if: ->(*) { can_stop_environment? } do |es|
stop_project_environment_path(es.project, es.environment)
end
diff --git a/app/serializers/lfs_file_lock_entity.rb b/app/serializers/lfs_file_lock_entity.rb
index 7961c4e666b..dd109cba015 100644
--- a/app/serializers/lfs_file_lock_entity.rb
+++ b/app/serializers/lfs_file_lock_entity.rb
@@ -5,9 +5,9 @@ class LfsFileLockEntity < Grape::Entity
expose :path
expose(:id) { |entity| entity.id.to_s }
- expose(:created_at, as: :locked_at) { |entity| entity.created_at.to_s(:iso8601) }
+ expose(:created_at, as: :locked_at) { |entity| entity.created_at.to_fs(:iso8601) }
expose :owner do
- expose(:name) { |entity| entity.user&.name }
+ expose(:name) { |entity| entity.user&.username }
end
end
diff --git a/app/serializers/prometheus_alert_entity.rb b/app/serializers/prometheus_alert_entity.rb
deleted file mode 100644
index fb25889e4db..00000000000
--- a/app/serializers/prometheus_alert_entity.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-class PrometheusAlertEntity < Grape::Entity
- include RequestAwareEntity
-
- expose :id
- expose :title
- expose :query
- expose :threshold
- expose :runbook_url
-
- expose :operator do |prometheus_alert|
- prometheus_alert.computed_operator
- end
-
- private
-
- alias_method :prometheus_alert, :object
-
- def can_read_prometheus_alerts?
- can?(request.current_user, :read_prometheus_alerts, prometheus_alert.project)
- end
-end
diff --git a/app/serializers/prometheus_alert_serializer.rb b/app/serializers/prometheus_alert_serializer.rb
deleted file mode 100644
index 4dafb7216db..00000000000
--- a/app/serializers/prometheus_alert_serializer.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-
-class PrometheusAlertSerializer < BaseSerializer
- entity PrometheusAlertEntity
-end
diff --git a/app/services/admin/plan_limits/update_service.rb b/app/services/admin/plan_limits/update_service.rb
index a71d1f14112..cda9a7e7f8c 100644
--- a/app/services/admin/plan_limits/update_service.rb
+++ b/app/services/admin/plan_limits/update_service.rb
@@ -7,26 +7,34 @@ module Admin
@current_user = current_user
@params = params
@plan = plan
+ @plan_limits = plan.actual_limits
end
def execute
return error(_('Access denied'), :forbidden) unless can_update?
- if plan.actual_limits.update(parsed_params)
+ add_history_to_params!
+
+ if plan_limits.update(parsed_params)
success
else
- error(plan.actual_limits.errors.full_messages, :bad_request)
+ error(plan_limits.errors.full_messages, :bad_request)
end
end
private
- attr_accessor :current_user, :params, :plan
+ attr_accessor :current_user, :params, :plan, :plan_limits
def can_update?
current_user.can_admin_all_resources?
end
+ def add_history_to_params!
+ formatted_limits_history = plan_limits.format_limits_history(current_user, parsed_params)
+ parsed_params.merge!(limits_history: formatted_limits_history) unless formatted_limits_history.empty?
+ end
+
# Overridden in EE
def parsed_params
params
diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb
index 7728982779e..6d484c4fa22 100644
--- a/app/services/application_settings/update_service.rb
+++ b/app/services/application_settings/update_service.rb
@@ -26,6 +26,7 @@ module ApplicationSettings
end
update_terms(@params.delete(:terms))
+ update_default_branch_protection_defaults(@params[:default_branch_protection])
add_to_outbound_local_requests_whitelist(@params.delete(:add_to_outbound_local_requests_whitelist))
@@ -77,6 +78,19 @@ module ApplicationSettings
@application_setting.reset_memoized_terms
end
+ def update_default_branch_protection_defaults(default_branch_protection)
+ return unless default_branch_protection.present?
+
+ # We are migrating default_branch_protection from an integer
+ # column to a jsonb column. While completing the rest of the
+ # work, we want to start translating the updates sent to the
+ # existing column into the json. Eventually, we will be updating
+ # the jsonb column directly and deprecating the original update
+ # path. Until then, we want to sync up both columns.
+ protection = Gitlab::Access::BranchProtection.new(default_branch_protection.to_i)
+ @application_setting.default_branch_protection_defaults = protection.to_hash
+ end
+
def process_performance_bar_allowed_group_id
group_full_path = params.delete(:performance_bar_allowed_group_path)
enable_param_on = Gitlab::Utils.to_boolean(params.delete(:performance_bar_enabled))
diff --git a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
index 2bbb8f925a4..cb8e531f0e1 100644
--- a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
+++ b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
@@ -4,9 +4,7 @@ module AutoMerge
class MergeWhenPipelineSucceedsService < AutoMerge::BaseService
def execute(merge_request)
super do
- if merge_request.saved_change_to_auto_merge_enabled?
- SystemNoteService.merge_when_pipeline_succeeds(merge_request, project, current_user, merge_request.actual_head_pipeline.sha)
- end
+ add_system_note(merge_request)
end
end
@@ -36,12 +34,20 @@ module AutoMerge
def available_for?(merge_request)
super do
- merge_request.actual_head_pipeline&.active?
+ check_availability(merge_request)
end
end
private
+ def add_system_note(merge_request)
+ SystemNoteService.merge_when_pipeline_succeeds(merge_request, project, current_user, merge_request.actual_head_pipeline.sha) if merge_request.saved_change_to_auto_merge_enabled?
+ end
+
+ def check_availability(merge_request)
+ merge_request.actual_head_pipeline&.active?
+ end
+
def notify(merge_request)
notification_service.async.merge_when_pipeline_succeeds(merge_request, current_user) if merge_request.saved_change_to_auto_merge_enabled?
end
diff --git a/app/services/award_emojis/add_service.rb b/app/services/award_emojis/add_service.rb
index f45a4330c09..065ef9dc708 100644
--- a/app/services/award_emojis/add_service.rb
+++ b/app/services/award_emojis/add_service.rb
@@ -27,6 +27,8 @@ module AwardEmojis
def after_create(award)
TodoService.new.new_award_emoji(todoable, current_user) if todoable
+
+ execute_hooks(award, 'award')
end
def todoable
diff --git a/app/services/award_emojis/base_service.rb b/app/services/award_emojis/base_service.rb
index 626e26d63b5..274c528acf2 100644
--- a/app/services/award_emojis/base_service.rb
+++ b/app/services/award_emojis/base_service.rb
@@ -11,6 +11,13 @@ module AwardEmojis
super(awardable.project, current_user)
end
+ def execute_hooks(award_emoji, action)
+ return unless awardable.project&.has_active_hooks?(:emoji_hooks)
+
+ hook_data = Gitlab::DataBuilder::Emoji.build(award_emoji, current_user, action)
+ awardable.project.execute_hooks(hook_data, :emoji_hooks)
+ end
+
private
def normalize_name(name)
diff --git a/app/services/award_emojis/destroy_service.rb b/app/services/award_emojis/destroy_service.rb
index 47dc8418e07..b7146d69bf0 100644
--- a/app/services/award_emojis/destroy_service.rb
+++ b/app/services/award_emojis/destroy_service.rb
@@ -22,6 +22,7 @@ module AwardEmojis
private
def after_destroy(award)
+ execute_hooks(award, 'revoke')
end
end
end
diff --git a/app/services/boards/base_items_list_service.rb b/app/services/boards/base_items_list_service.rb
index bf68aee2c1f..a4b1be1e599 100644
--- a/app/services/boards/base_items_list_service.rb
+++ b/app/services/boards/base_items_list_service.rb
@@ -13,14 +13,17 @@ module Boards
# rubocop: disable CodeReuse/ActiveRecord
def metadata(required_fields = [:issue_count, :total_issue_weight])
- fields = metadata_fields(required_fields)
- keys = fields.keys
- # TODO: eliminate need for SQL literal fragment
- columns = Arel.sql(fields.values_at(*keys).join(', '))
- results = item_model.where(id: collection_ids)
- results = results.select(columns)
-
- Hash[keys.zip(results.pluck(columns).flatten)]
+ # Failing tests in spec/requests/api/graphql/boards/board_lists_query_spec.rb
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417465") do
+ fields = metadata_fields(required_fields)
+ keys = fields.keys
+ # TODO: eliminate need for SQL literal fragment
+ columns = Arel.sql(fields.values_at(*keys).join(', '))
+ results = item_model.where(id: collection_ids)
+ results = results.select(columns)
+
+ Hash[keys.zip(results.pluck(columns).flatten)]
+ end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb
index 636c636255f..7fc3511a253 100644
--- a/app/services/bulk_imports/create_service.rb
+++ b/app/services/bulk_imports/create_service.rb
@@ -105,7 +105,7 @@ module BulkImports
def validate_setting_enabled!
source_full_path, source_type = Array.wrap(params)[0].values_at(:source_full_path, :source_type)
entity_type = ENTITY_TYPES_MAPPING.fetch(source_type)
- if source_full_path =~ /^[0-9]+$/
+ if /^[0-9]+$/.match?(source_full_path)
query = query_type(entity_type)
response = graphql_client.execute(
graphql_client.parse(query.to_s),
@@ -154,7 +154,7 @@ module BulkImports
end
def validate_destination_slug(destination_slug)
- return if destination_slug =~ Gitlab::Regex.oci_repository_path_regex
+ return if Gitlab::Regex.oci_repository_path_regex.match?(destination_slug)
raise BulkImports::Error.destination_slug_validation_failure
end
diff --git a/app/services/bulk_imports/relation_export_service.rb b/app/services/bulk_imports/relation_export_service.rb
index 142bc48efe3..ed71c09420b 100644
--- a/app/services/bulk_imports/relation_export_service.rb
+++ b/app/services/bulk_imports/relation_export_service.rb
@@ -16,7 +16,7 @@ module BulkImports
def execute
find_or_create_export! do |export|
- remove_existing_export_file!(export)
+ export.remove_existing_upload!
export_service.execute
compress_exported_relation
upload_compressed_file(export)
@@ -45,15 +45,6 @@ module BulkImports
fail_export!(export, e)
end
- def remove_existing_export_file!(export)
- upload = export.upload
-
- return unless upload&.export_file&.file
-
- upload.remove_export_file!
- upload.save!
- end
-
def export_service
@export_service ||= if config.tree_relation?(relation) || config.self_relation?(relation)
TreeExportService.new(portable, export_path, relation, user)
diff --git a/app/services/ci/create_pipeline_schedule_service.rb b/app/services/ci/create_pipeline_schedule_service.rb
index 0d5f50c26a1..4fdd65bcdb4 100644
--- a/app/services/ci/create_pipeline_schedule_service.rb
+++ b/app/services/ci/create_pipeline_schedule_service.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
module Ci
+ # This class is deprecated and will be removed with the FF ci_refactoring_pipeline_schedule_create_service
class CreatePipelineScheduleService < BaseService
def execute
project.pipeline_schedules.create(pipeline_schedule_params)
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index a8da83e84a1..fe0e842f542 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -40,22 +40,22 @@ module Ci
# Create a new pipeline in the specified project.
#
- # @param [Symbol] source What event (Ci::Pipeline.sources) triggers the pipeline
- # creation.
- # @param [Boolean] ignore_skip_ci Whether skipping a pipeline creation when `[skip ci]` comment
- # is present in the commit body
- # @param [Boolean] save_on_errors Whether persisting an invalid pipeline when it encounters an
- # error during creation (e.g. invalid yaml)
- # @param [Ci::TriggerRequest] trigger_request The pipeline trigger triggers the pipeline creation.
- # @param [Ci::PipelineSchedule] schedule The pipeline schedule triggers the pipeline creation.
- # @param [MergeRequest] merge_request The merge request triggers the pipeline creation.
- # @param [ExternalPullRequest] external_pull_request The external pull request triggers the pipeline creation.
- # @param [Ci::Bridge] bridge The bridge job that triggers the downstream pipeline creation.
- # @param [String] content The content of .gitlab-ci.yml to override the default config
- # contents (e.g. .gitlab-ci.yml in repostiry). Mainly used for
- # generating a dangling pipeline.
+ # @param [Symbol] source What event (Ci::Pipeline.sources) triggers the pipeline
+ # creation.
+ # @param [Boolean] ignore_skip_ci Whether skipping a pipeline creation when `[skip ci]` comment
+ # is present in the commit body
+ # @param [Boolean] save_on_errors Whether persisting an invalid pipeline when it encounters an
+ # error during creation (e.g. invalid yaml)
+ # @param [Ci::TriggerRequest] trigger_request The pipeline trigger triggers the pipeline creation.
+ # @param [Ci::PipelineSchedule] schedule The pipeline schedule triggers the pipeline creation.
+ # @param [MergeRequest] merge_request The merge request triggers the pipeline creation.
+ # @param [Ci::ExternalPullRequest] external_pull_request The external pull request triggers the pipeline creation.
+ # @param [Ci::Bridge] bridge The bridge job that triggers the downstream pipeline creation.
+ # @param [String] content The content of .gitlab-ci.yml to override the default config
+ # contents (e.g. .gitlab-ci.yml in repostiry). Mainly used for
+ # generating a dangling pipeline.
#
- # @return [Ci::Pipeline] The created Ci::Pipeline object.
+ # @return [Ci::Pipeline] The created Ci::Pipeline object.
# rubocop: disable Metrics/ParameterLists
def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, external_pull_request: nil, bridge: nil, **options, &block)
@logger = build_logger
diff --git a/app/services/ci/destroy_pipeline_service.rb b/app/services/ci/destroy_pipeline_service.rb
index bdec13f98a7..a9d2e17657e 100644
--- a/app/services/ci/destroy_pipeline_service.rb
+++ b/app/services/ci/destroy_pipeline_service.rb
@@ -7,7 +7,7 @@ module Ci
Ci::ExpirePipelineCacheService.new.execute(pipeline, delete: true)
- # ensure cancellation happens sync so we accumulate compute credits successfully
+ # ensure cancellation happens sync so we accumulate compute minutes successfully
# before deleting the pipeline.
::Ci::CancelPipelineService.new(
pipeline: pipeline,
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb
index c0ffbb401f6..8211507fb95 100644
--- a/app/services/ci/pipeline_processing/atomic_processing_service.rb
+++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb
@@ -23,14 +23,12 @@ module Ci
success = try_obtain_lease { process! }
if success
- if ::Feature.enabled?(:ci_reset_skipped_jobs_in_atomic_processing, project)
- # If any jobs changed from stopped to alive status during pipeline processing, we must
- # re-reset their dependent jobs; see https://gitlab.com/gitlab-org/gitlab/-/issues/388539.
- new_alive_jobs.group_by(&:user).each do |user, jobs|
- log_running_reset_skipped_jobs_service(jobs)
-
- ResetSkippedJobsService.new(project, user).execute(jobs)
- end
+ # If any jobs changed from stopped to alive status during pipeline processing, we must
+ # re-reset their dependent jobs; see https://gitlab.com/gitlab-org/gitlab/-/issues/388539.
+ new_alive_jobs.group_by(&:user).each do |user, jobs|
+ log_running_reset_skipped_jobs_service(jobs)
+
+ ResetSkippedJobsService.new(project, user).execute(jobs)
end
# Re-schedule if we need further processing
diff --git a/app/services/ci/pipeline_schedules/create_service.rb b/app/services/ci/pipeline_schedules/create_service.rb
new file mode 100644
index 00000000000..c1825865bc0
--- /dev/null
+++ b/app/services/ci/pipeline_schedules/create_service.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Ci
+ module PipelineSchedules
+ class CreateService
+ def initialize(project, user, params)
+ @project = project
+ @user = user
+ @params = params
+
+ @schedule = project.pipeline_schedules.new
+ end
+
+ def execute
+ return forbidden unless allowed?
+
+ schedule.assign_attributes(params.merge(owner: user))
+
+ if schedule.save
+ ServiceResponse.success(payload: schedule)
+ else
+ ServiceResponse.error(payload: schedule, message: schedule.errors.full_messages)
+ end
+ end
+
+ private
+
+ attr_reader :project, :user, :params, :schedule
+
+ def allowed?
+ user.can?(:create_pipeline_schedule, schedule)
+ end
+
+ def forbidden
+ # We add the error to the base object too
+ # because model errors are used in the API responses and the `form_errors` helper.
+ schedule.errors.add(:base, forbidden_message)
+
+ ServiceResponse.error(payload: schedule, message: [forbidden_message], reason: :forbidden)
+ end
+
+ def forbidden_message
+ _('The current user is not authorized to create the pipeline schedule')
+ end
+ end
+ end
+end
diff --git a/app/services/ci/pipeline_schedules/update_service.rb b/app/services/ci/pipeline_schedules/update_service.rb
index 2412b5cbd81..28c22e0a868 100644
--- a/app/services/ci/pipeline_schedules/update_service.rb
+++ b/app/services/ci/pipeline_schedules/update_service.rb
@@ -12,7 +12,9 @@ module Ci
def execute
return forbidden unless allowed?
- if schedule.update(@params)
+ schedule.assign_attributes(params)
+
+ if schedule.save
ServiceResponse.success(payload: schedule)
else
ServiceResponse.error(message: schedule.errors.full_messages)
@@ -21,17 +23,22 @@ module Ci
private
- attr_reader :schedule, :user
+ attr_reader :schedule, :user, :params
def allowed?
user.can?(:update_pipeline_schedule, schedule)
end
def forbidden
- ServiceResponse.error(
- message: _('The current user is not authorized to update the pipeline schedule'),
- reason: :forbidden
- )
+ # We add the error to the base object too
+ # because model errors are used in the API responses and the `form_errors` helper.
+ schedule.errors.add(:base, forbidden_message)
+
+ ServiceResponse.error(message: [forbidden_message], reason: :forbidden)
+ end
+
+ def forbidden_message
+ _('The current user is not authorized to update the pipeline schedule')
end
end
end
diff --git a/app/services/clusters/agent_tokens/create_service.rb b/app/services/clusters/agent_tokens/create_service.rb
index efa9716d2c8..136afd108e7 100644
--- a/app/services/clusters/agent_tokens/create_service.rb
+++ b/app/services/clusters/agent_tokens/create_service.rb
@@ -40,8 +40,6 @@ module Clusters
end
def active_tokens_limit_reached?
- return false unless Feature.enabled?(:cluster_agents_limit_tokens_created)
-
::Clusters::AgentTokensFinder.new(agent, current_user, status: :active).execute.count >= ACTIVE_TOKENS_LIMIT
end
diff --git a/app/services/clusters/agents/authorize_proxy_user_service.rb b/app/services/clusters/agents/authorize_proxy_user_service.rb
index fbcf25153c1..abf451ed350 100644
--- a/app/services/clusters/agents/authorize_proxy_user_service.rb
+++ b/app/services/clusters/agents/authorize_proxy_user_service.rb
@@ -11,17 +11,14 @@ module Clusters
end
def execute
- return forbidden unless user_access_config.present?
+ return forbidden('`user_access` keyword is not found in agent config file.') unless user_access_config.present?
access_as = user_access_config['access_as']
- return forbidden unless access_as.present?
- return forbidden if access_as.size != 1
- if payload = handle_access(access_as)
- return success(payload: payload)
- end
+ return forbidden('`access_as` is not found under the `user_access` keyword.') unless access_as.present?
+ return forbidden('`access_as` must exist only once under the `user_access` keyword.') if access_as.size != 1
- forbidden
+ handle_access(access_as)
end
private
@@ -52,9 +49,11 @@ module Clusters
end
def access_as_agent
- return if authorizations.empty?
+ if authorizations.empty?
+ return forbidden('You must be a member of `projects` or `groups` under the `user_access` keyword.')
+ end
- response_base.merge(access_as: { agent: {} })
+ success(payload: response_base.merge(access_as: { agent: {} }))
end
def user_access_config
@@ -64,8 +63,8 @@ module Clusters
delegate :success, to: ServiceResponse, private: true
- def forbidden
- ServiceResponse.error(reason: :forbidden, message: '403 Forbidden')
+ def forbidden(message)
+ ServiceResponse.error(reason: :forbidden, message: message)
end
end
end
diff --git a/app/services/clusters/cleanup/project_namespace_service.rb b/app/services/clusters/cleanup/project_namespace_service.rb
index 80192aa14ab..f6ac06d0594 100644
--- a/app/services/clusters/cleanup/project_namespace_service.rb
+++ b/app/services/clusters/cleanup/project_namespace_service.rb
@@ -29,7 +29,7 @@ module Clusters
rescue Kubeclient::HttpError => e
# unauthorized, forbidden: GitLab's access has been revoked
# certificate verify failed: Cluster is probably gone forever
- raise unless e.message =~ /unauthorized|forbidden|certificate verify failed/i
+ raise unless /unauthorized|forbidden|certificate verify failed/i.match?(e.message)
end
kubernetes_namespace.destroy!
diff --git a/app/services/clusters/cleanup/service_account_service.rb b/app/services/clusters/cleanup/service_account_service.rb
index dce41d2a39c..0ce4bf9bb9c 100644
--- a/app/services/clusters/cleanup/service_account_service.rb
+++ b/app/services/clusters/cleanup/service_account_service.rb
@@ -27,7 +27,7 @@ module Clusters
rescue Kubeclient::HttpError => e
# unauthorized, forbidden: GitLab's access has been revoked
# certificate verify failed: Cluster is probably gone forever
- raise unless e.message =~ /unauthorized|forbidden|certificate verify failed/i
+ raise unless /unauthorized|forbidden|certificate verify failed/i.match?(e.message)
end
end
end
diff --git a/app/services/clusters/integrations/prometheus_health_check_service.rb b/app/services/clusters/integrations/prometheus_health_check_service.rb
deleted file mode 100644
index cd06e59449c..00000000000
--- a/app/services/clusters/integrations/prometheus_health_check_service.rb
+++ /dev/null
@@ -1,101 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Integrations
- class PrometheusHealthCheckService
- include Gitlab::Utils::StrongMemoize
- include Gitlab::Routing
-
- def initialize(cluster)
- @cluster = cluster
- @logger = Gitlab::AppJsonLogger.build
- end
-
- def execute
- raise 'Invalid cluster type. Only project types are allowed.' unless @cluster.project_type?
-
- return unless prometheus_integration.enabled
-
- project = @cluster.clusterable
-
- @logger.info(
- message: 'Prometheus health check',
- cluster_id: @cluster.id,
- newly_unhealthy: became_unhealthy?,
- currently_healthy: currently_healthy?,
- was_healthy: was_healthy?
- )
-
- send_notification(project) if became_unhealthy?
-
- prometheus_integration.update_columns(health_status: current_health_status) if health_changed?
- end
-
- private
-
- def prometheus_integration
- strong_memoize(:prometheus_integration) do
- @cluster.integration_prometheus
- end
- end
-
- def current_health_status
- if currently_healthy?
- :healthy
- else
- :unhealthy
- end
- end
-
- def currently_healthy?
- strong_memoize(:currently_healthy) do
- prometheus_integration.prometheus_client.healthy?
- end
- end
-
- def became_unhealthy?
- strong_memoize(:became_unhealthy) do
- (was_healthy? || was_unknown?) && !currently_healthy?
- end
- end
-
- def was_healthy?
- strong_memoize(:was_healthy) do
- prometheus_integration.healthy?
- end
- end
-
- def was_unknown?
- strong_memoize(:was_unknown) do
- prometheus_integration.unknown?
- end
- end
-
- def health_changed?
- was_healthy? != currently_healthy?
- end
-
- def send_notification(project)
- notification_payload = build_notification_payload(project)
- integration = project.alert_management_http_integrations.active.first
-
- Projects::Alerting::NotifyService.new(project, notification_payload).execute(integration&.token, integration)
-
- @logger.info(message: 'Successfully notified of Prometheus newly unhealthy', cluster_id: @cluster.id, project_id: project.id)
- end
-
- def build_notification_payload(project)
- cluster_path = namespace_project_cluster_path(
- project_id: project.path,
- namespace_id: project.namespace.path,
- id: @cluster.id
- )
-
- {
- title: "Prometheus is Unhealthy. Cluster Name: #{@cluster.name}",
- description: "Prometheus is unhealthy for the cluster: [#{@cluster.name}](#{cluster_path}) attached to project #{project.name}."
- }
- 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 b3427697052..fcef22a8cab 100644
--- a/app/services/concerns/integrations/project_test_data.rb
+++ b/app/services/concerns/integrations/project_test_data.rb
@@ -77,5 +77,20 @@ module Integrations
release.to_hook_data('create')
end
+
+ def emoji_events_data
+ no_data_error(s_('TestHooks|Ensure the project has notes.')) unless project.notes.any?
+
+ award_emoji = AwardEmoji.new(
+ id: 1,
+ name: 'thumbsup',
+ user: current_user,
+ awardable: project.notes.last,
+ created_at: Time.zone.now,
+ updated_at: Time.zone.now
+ )
+
+ Gitlab::DataBuilder::Emoji.build(award_emoji, current_user, 'award')
+ end
end
end
diff --git a/app/services/concerns/projects/remove_refs.rb b/app/services/concerns/projects/remove_refs.rb
new file mode 100644
index 00000000000..d133aa0ced6
--- /dev/null
+++ b/app/services/concerns/projects/remove_refs.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Projects
+ module RemoveRefs
+ extend ActiveSupport::Concern
+ include Gitlab::ExclusiveLeaseHelpers
+
+ LOCK_RETRY = 3
+ LOCK_TTL = 5.minutes
+ LOCK_SLEEP = 0.5.seconds
+
+ def serialized_remove_refs(project_id, &blk)
+ in_lock("projects/#{project_id}/serialized_remove_refs", **lock_params, &blk)
+ end
+
+ def lock_params
+ {
+ ttl: LOCK_TTL,
+ retries: LOCK_RETRY,
+ sleep_sec: LOCK_SLEEP
+ }
+ end
+ end
+end
diff --git a/app/services/draft_notes/create_service.rb b/app/services/draft_notes/create_service.rb
index 5ff971b66c1..e5a070e9db7 100644
--- a/app/services/draft_notes/create_service.rb
+++ b/app/services/draft_notes/create_service.rb
@@ -25,7 +25,8 @@ module DraftNotes
draft_note = DraftNote.new(params)
draft_note.merge_request = merge_request
draft_note.author = current_user
- draft_note.save
+
+ return draft_note unless draft_note.save
if in_reply_to_discussion_id.blank? && draft_note.diff_file&.unfolded?
merge_request.diffs.clear_cache
diff --git a/app/services/draft_notes/publish_service.rb b/app/services/draft_notes/publish_service.rb
index 9e1e381c568..a7a2ad63c1c 100644
--- a/app/services/draft_notes/publish_service.rb
+++ b/app/services/draft_notes/publish_service.rb
@@ -49,6 +49,7 @@ module DraftNotes
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)
+ after_publish(review)
end
def create_note_from_draft(draft, skip_capture_diff_note_position: false, skip_keep_around_commits: false, skip_merge_status_trigger: false)
@@ -108,5 +109,11 @@ module DraftNotes
project.repository.keep_around(*shas)
end
end
+
+ def after_publish(review)
+ # Overridden in EE
+ end
end
end
+
+DraftNotes::PublishService.prepend_mod
diff --git a/app/services/environments/create_service.rb b/app/services/environments/create_service.rb
index 760c8a6e306..fd78a886e29 100644
--- a/app/services/environments/create_service.rb
+++ b/app/services/environments/create_service.rb
@@ -2,7 +2,7 @@
module Environments
class CreateService < BaseService
- ALLOWED_ATTRIBUTES = %i[name external_url tier cluster_agent].freeze
+ ALLOWED_ATTRIBUTES = %i[name external_url tier cluster_agent kubernetes_namespace].freeze
def execute
unless can?(current_user, :create_environment, project)
diff --git a/app/services/environments/update_service.rb b/app/services/environments/update_service.rb
index 5eb4880ec4b..52f6198bada 100644
--- a/app/services/environments/update_service.rb
+++ b/app/services/environments/update_service.rb
@@ -2,7 +2,7 @@
module Environments
class UpdateService < BaseService
- ALLOWED_ATTRIBUTES = %i[external_url tier cluster_agent].freeze
+ ALLOWED_ATTRIBUTES = %i[external_url tier cluster_agent kubernetes_namespace].freeze
def execute(environment)
unless can?(current_user, :update_environment, environment)
diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb
index acf54dec51b..f9280be7ee2 100644
--- a/app/services/git/base_hooks_service.rb
+++ b/app/services/git/base_hooks_service.rb
@@ -67,7 +67,10 @@ module Git
# Creating push_data invokes one CommitDelta RPC per commit. Only
# build this data if we actually need it.
project.execute_hooks(push_data, hook_name) if project.has_active_hooks?(hook_name)
- project.execute_integrations(push_data, hook_name) if project.has_active_integrations?(hook_name)
+
+ return unless project.has_active_integrations?(hook_name)
+
+ project.execute_integrations(push_data, hook_name, skip_ci: integration_push_options&.fetch(:skip_ci).present?)
end
def enqueue_invalidate_cache
@@ -101,7 +104,19 @@ module Git
def ci_variables_from_push_options
strong_memoize(:ci_variables_from_push_options) do
- params[:push_options]&.deep_symbolize_keys&.dig(:ci, :variable)
+ push_options&.dig(:ci, :variable)
+ end
+ end
+
+ def integration_push_options
+ strong_memoize(:integration_push_options) do
+ push_options&.dig(:integrations)
+ end
+ end
+
+ def push_options
+ strong_memoize(:push_options) do
+ params[:push_options]&.deep_symbolize_keys
end
end
diff --git a/app/services/groups/participants_service.rb b/app/services/groups/participants_service.rb
index 1de2b3c5a2e..e939d27d464 100644
--- a/app/services/groups/participants_service.rb
+++ b/app/services/groups/participants_service.rb
@@ -2,6 +2,7 @@
module Groups
class ParticipantsService < Groups::BaseService
+ include Gitlab::Utils::StrongMemoize
include Users::ParticipableService
def execute(noteable)
@@ -17,15 +18,20 @@ module Groups
render_participants_as_hash(participants.uniq)
end
+ private
+
def all_members
- count = group_members.count
- [{ username: "all", name: "All Group Members", count: count }]
+ return [] if group.nil? || Feature.enabled?(:disable_all_mention)
+
+ [{ username: "all", name: "All Group Members", count: group.users_count }]
end
def group_members
return [] unless group
- @group_members ||= sorted(group.direct_and_indirect_users)
+ sorted(
+ group.direct_and_indirect_users(share_with_groups: group.member?(current_user))
+ )
end
end
end
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index 16454360ee2..81d4dfddaab 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -197,6 +197,11 @@ module Groups
return if @new_parent_group
return unless @group.owners.empty?
+ add_owner_on_transferred_group
+ end
+
+ # Overridden in EE
+ def add_owner_on_transferred_group
@group.add_owner(current_user)
end
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index 925a2acbb58..df6ede87ef9 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -117,6 +117,7 @@ module Groups
def handle_settings_update
settings_params = params.slice(*allowed_settings_params)
+ settings_params.merge!({ default_branch_protection: params[:default_branch_protection] }.compact)
allowed_settings_params.each { |param| params.delete(param) }
::NamespaceSettings::UpdateService.new(current_user, group, settings_params).execute
diff --git a/app/services/groups/update_shared_runners_service.rb b/app/services/groups/update_shared_runners_service.rb
index c09dce0761f..08b43037c4c 100644
--- a/app/services/groups/update_shared_runners_service.rb
+++ b/app/services/groups/update_shared_runners_service.rb
@@ -25,7 +25,14 @@ module Groups
end
def update_shared_runners
- group.update_shared_runners_setting!(params[:shared_runners_setting])
+ case params[:shared_runners_setting]
+ when Namespace::SR_DISABLED_AND_UNOVERRIDABLE
+ set_shared_runners_enabled!(false)
+ when Namespace::SR_DISABLED_WITH_OVERRIDE, Namespace::SR_DISABLED_AND_OVERRIDABLE
+ disable_shared_runners_and_allow_override!
+ when Namespace::SR_ENABLED
+ set_shared_runners_enabled!(true)
+ end
end
def update_pending_builds?
@@ -41,5 +48,38 @@ module Groups
::Ci::UpdatePendingBuildService.new(group, pending_builds_params).execute
end
end
+
+ def set_shared_runners_enabled!(enabled)
+ group.update!(
+ shared_runners_enabled: enabled,
+ allow_descendants_override_disabled_shared_runners: false)
+
+ group_ids = group.descendants
+ unless group_ids.empty?
+ Group.by_id(group_ids).update_all(
+ shared_runners_enabled: enabled,
+ allow_descendants_override_disabled_shared_runners: false)
+ end
+
+ group.all_projects.update_all(shared_runners_enabled: enabled)
+ end
+
+ def disable_shared_runners_and_allow_override!
+ # enabled -> disabled_and_overridable
+ if group.shared_runners_enabled?
+ group.update!(
+ shared_runners_enabled: false,
+ allow_descendants_override_disabled_shared_runners: true)
+
+ group_ids = group.descendants
+ Group.by_id(group_ids).update_all(shared_runners_enabled: false) unless group_ids.empty?
+
+ group.all_projects.update_all(shared_runners_enabled: false)
+
+ # disabled_and_unoverridable -> disabled_and_overridable
+ else
+ group.update!(allow_descendants_override_disabled_shared_runners: true)
+ end
+ end
end
end
diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb
index 7e7f7ea9810..df255a7ae24 100644
--- a/app/services/import/github_service.rb
+++ b/app/services/import/github_service.rb
@@ -16,7 +16,7 @@ module Import
track_access_level('github')
if project.persisted?
- store_import_settings(project)
+ store_import_settings(project, access_params)
success(project)
elsif project.errors[:import_source_disabled].present?
error(project.errors[:import_source_disabled], :forbidden)
@@ -134,8 +134,13 @@ module Import
error(translated_message, http_status)
end
- def store_import_settings(project)
- Gitlab::GithubImport::Settings.new(project).write(params[:optional_stages])
+ def store_import_settings(project, access_params)
+ Gitlab::GithubImport::Settings
+ .new(project)
+ .write(
+ optional_stages: params[:optional_stages],
+ additional_access_tokens: access_params[:additional_access_tokens]
+ )
end
end
end
diff --git a/app/services/import_csv/base_service.rb b/app/services/import_csv/base_service.rb
index cfaf3e831eb..9c1bad9e7da 100644
--- a/app/services/import_csv/base_service.rb
+++ b/app/services/import_csv/base_service.rb
@@ -8,7 +8,7 @@ module ImportCsv
@user = user
@project = project
@csv_io = csv_io
- @results = { success: 0, error_lines: [], parse_error: false }
+ @results = { success: 0, error_lines: [], parse_error: false, preprocess_errors: {} }
end
PreprocessError = Class.new(StandardError)
diff --git a/app/services/import_csv/preprocess_milestones_service.rb b/app/services/import_csv/preprocess_milestones_service.rb
new file mode 100644
index 00000000000..97fb381c58e
--- /dev/null
+++ b/app/services/import_csv/preprocess_milestones_service.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module ImportCsv
+ class PreprocessMilestonesService < BaseService
+ def initialize(user, project, provided_titles)
+ @user = user
+ @project = project
+ @provided_titles = provided_titles
+
+ @results = { success: 0, errors: nil }
+ @milestone_errors = { missing: { header: {}, titles: [] } }
+ end
+
+ attr_reader :user, :project, :provided_titles, :results, :milestone_errors
+
+ def execute
+ available_milestones = find_milestones_by_titles
+ return ServiceResponse.success if provided_titles.sort == available_milestones.sort
+
+ milestone_errors[:missing][:header] = 'Milestone'
+ milestone_errors[:missing][:titles] = provided_titles.difference(available_milestones) || []
+ ServiceResponse.error(message: "", payload: milestone_errors)
+ end
+
+ def find_milestones_by_titles
+ # Find if these milestones exist in the project or its group and group ancestors
+ finder_params = {
+ project_ids: [project.id],
+ title: provided_titles
+ }
+ finder_params[:group_ids] = project.group.self_and_ancestors.select(:id) if project.group
+ MilestonesFinder.new(finder_params).execute.map(&:title).uniq
+ end
+ end
+end
diff --git a/app/services/integrations/group_mention_service.rb b/app/services/integrations/group_mention_service.rb
new file mode 100644
index 00000000000..2389bf33432
--- /dev/null
+++ b/app/services/integrations/group_mention_service.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+# GroupMentionService class
+#
+# Used for sending group mention notifications
+#
+# Ex.
+# Integrations::GroupMentionService.new(mentionable, hook_data: data, is_confidential: true).execute
+#
+module Integrations
+ class GroupMentionService
+ def initialize(mentionable, hook_data:, is_confidential:)
+ @mentionable = mentionable
+ @hook_data = hook_data
+ @is_confidential = is_confidential
+ end
+
+ def execute
+ return ServiceResponse.success if mentionable.nil? || hook_data.nil?
+
+ @hook_data = hook_data.clone
+ # Fake a "group_mention" object kind so integrations can handle this as a separate class of event
+ hook_data[:object_attributes][:object_kind] = hook_data[:object_kind]
+ hook_data[:object_kind] = 'group_mention'
+
+ if confidential?
+ hook_data[:event_type] = 'group_confidential_mention'
+ hook_scope = :group_confidential_mention_hooks
+ else
+ hook_data[:event_type] = 'group_mention'
+ hook_scope = :group_mention_hooks
+ end
+
+ groups = mentionable.referenced_groups(mentionable.author)
+ groups.each do |group|
+ group_hook_data = hook_data.merge(
+ mentioned: {
+ object_kind: 'group',
+ name: group.full_path,
+ url: group.web_url
+ }
+ )
+ group.execute_integrations(group_hook_data, hook_scope)
+ end
+
+ ServiceResponse.success
+ end
+
+ private
+
+ attr_reader :mentionable, :hook_data, :is_confidential
+
+ def confidential?
+ return is_confidential if is_confidential.present?
+
+ mentionable.project.visibility_level != Gitlab::VisibilityLevel::PUBLIC
+ end
+ end
+end
diff --git a/app/services/integrations/test/project_service.rb b/app/services/integrations/test/project_service.rb
index 31c8f02c7b6..48240f297fe 100644
--- a/app/services/integrations/test/project_service.rb
+++ b/app/services/integrations/test/project_service.rb
@@ -35,6 +35,8 @@ module Integrations
deployment_events_data
when 'release'
releases_events_data
+ when 'award_emoji'
+ emoji_events_data
end
end
end
diff --git a/app/services/issuable/import_csv/base_service.rb b/app/services/issuable/import_csv/base_service.rb
index 9ef9fb76e3c..95338374ca6 100644
--- a/app/services/issuable/import_csv/base_service.rb
+++ b/app/services/issuable/import_csv/base_service.rb
@@ -23,6 +23,24 @@ module Issuable
raise CSV::MalformedCSVError.new('Invalid CSV format - missing required headers.', 1)
end
+
+ def preprocess!
+ preprocess_milestones!
+
+ raise PreprocessError if results[:preprocess_errors].any?
+ end
+
+ def preprocess_milestones!
+ # Pre-Process Milestone if header is present
+ return unless csv_data.lines.first.downcase.include?('milestone')
+
+ provided_titles = with_csv_lines.filter_map { |row| row[:milestone]&.strip&.downcase }.uniq
+ result = ::ImportCsv::PreprocessMilestonesService.new(user, project, provided_titles).execute
+ return if result.success?
+
+ # collate errors here and throw errors
+ results[:preprocess_errors][:milestone_errors] = result.payload
+ end
end
end
end
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index f982d66eb08..b9b7cd08b68 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -111,6 +111,10 @@ module Issues
issue.namespace.execute_integrations(issue_data, hooks_scope)
execute_incident_hooks(issue, issue_data) if issue.work_item_type&.incident?
+
+ return unless Feature.enabled?(:group_mentions, issue.project)
+
+ execute_group_mention_hooks(issue, issue_data) if action == 'open'
end
# We can remove this code after proposal in
@@ -121,6 +125,21 @@ module Issues
issue.namespace.execute_integrations(issue_data, :incident_hooks)
end
+ def execute_group_mention_hooks(issue, issue_data)
+ return unless issue.instance_of?(Issue)
+
+ args = {
+ mentionable_type: 'Issue',
+ mentionable_id: issue.id,
+ hook_data: issue_data,
+ is_confidential: issue.confidential?
+ }
+
+ issue.run_after_commit_or_now do
+ Integrations::GroupMentionWorker.perform_async(args)
+ end
+ end
+
def update_project_counter_caches?(issue)
super || issue.confidential_changed?
end
diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb
index a65fc0c7c87..63cad593936 100644
--- a/app/services/issues/build_service.rb
+++ b/app/services/issues/build_service.rb
@@ -83,18 +83,17 @@ module Issues
params.delete(:work_item_type)
end
- base_type = work_item_type&.base_type
-
- if create_issue_type_allowed?(container, base_type)
- issue.work_item_type = work_item_type
- # Up to this point issue_type might be set to the default, so we need to sync if a work item type is provided
- issue.issue_type = base_type
- else
- # If no work item type was provided or not allowed, we need to set it to issue_type,
- # and that includes the column default
- issue_type = issue_params[:issue_type] || ::Issue::DEFAULT_ISSUE_TYPE
- issue.work_item_type = WorkItems::Type.default_by_type(issue_type)
- end
+ # We need to support the legacy input params[:issue_type] even if we don't have the issue_type column anymore.
+ # In the future only params[:work_item_type] should be provided
+ base_type = work_item_type&.base_type || params[:issue_type]
+
+ issue.work_item_type = if create_issue_type_allowed?(container, base_type)
+ work_item_type || WorkItems::Type.default_by_type(base_type)
+ else
+ # If no work item type was provided or not allowed, we need to set it to
+ # the default issue_type
+ WorkItems::Type.default_by_type(::Issue::DEFAULT_ISSUE_TYPE)
+ end
end
def model_klass
@@ -109,8 +108,6 @@ module Issues
:confidential
]
- public_issue_params << :issue_type if create_issue_type_allowed?(container, params[:issue_type])
-
params.slice(*public_issue_params)
end
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index 17b6866773e..e1ddfe47439 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -51,6 +51,7 @@ module Issues
# current_user (defined in BaseService) is not available within run_after_commit block
user = current_user
+ assign_description_from_template(issue)
issue.run_after_commit do
NewIssueWorker.perform_async(issue.id, user.id, issue.class.to_s)
Issues::PlacementWorker.perform_async(nil, issue.project_id)
@@ -127,6 +128,35 @@ module Issues
set_crm_contacts(issue, contacts)
end
+
+ def assign_description_from_template(issue)
+ return if issue.description.present?
+
+ # Find the exact name for the default template (if the project has one).
+ # Since there are multiple possibilities regarding the capitalization(s) that the
+ # default template file name can have, getting the exact template name here will
+ # allow us to extract the contents later, and bail early if the project does not have
+ # a default template
+ templates = TemplateFinder.all_template_names(project, :issues)
+ template = templates.values.flatten.find { |tmpl| tmpl[:name].casecmp?('default') }
+
+ return unless template
+
+ begin
+ default_template = TemplateFinder.build(
+ :issues,
+ issue.project,
+ {
+ name: template[:name],
+ source_template_project_id: issue.project.id
+ }
+ ).execute
+ rescue ::Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError
+ nil
+ end
+
+ issue.description = default_template.content if default_template.present?
+ end
end
end
diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb
index 9e524d90505..99c0e9f1a37 100644
--- a/app/services/issues/export_csv_service.rb
+++ b/app/services/issues/export_csv_service.rb
@@ -34,14 +34,14 @@ module Issues
'Assignee Username' => -> (issue) { issue.assignees.map(&:username).join(', ') },
'Confidential' => -> (issue) { issue.confidential? ? 'Yes' : 'No' },
'Locked' => -> (issue) { issue.discussion_locked? ? 'Yes' : 'No' },
- 'Due Date' => -> (issue) { issue.due_date&.to_s(:csv) },
- 'Created At (UTC)' => -> (issue) { issue.created_at&.to_s(:csv) },
- 'Updated At (UTC)' => -> (issue) { issue.updated_at&.to_s(:csv) },
- 'Closed At (UTC)' => -> (issue) { issue.closed_at&.to_s(:csv) },
+ 'Due Date' => -> (issue) { issue.due_date&.to_fs(:csv) },
+ 'Created At (UTC)' => -> (issue) { issue.created_at&.to_fs(:csv) },
+ 'Updated At (UTC)' => -> (issue) { issue.updated_at&.to_fs(:csv) },
+ 'Closed At (UTC)' => -> (issue) { issue.closed_at&.to_fs(:csv) },
'Milestone' => -> (issue) { issue.milestone&.title },
'Weight' => -> (issue) { issue.weight },
'Labels' => -> (issue) { issue_labels(issue) },
- 'Time Estimate' => ->(issue) { issue.time_estimate.to_s(:csv) },
+ 'Time Estimate' => ->(issue) { issue.time_estimate.to_fs(:csv) },
'Time Spent' => -> (issue) { issue_time_spent(issue) }
}
end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index 7ad56d5a755..839d0e664a4 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -32,10 +32,9 @@ module Issues
end
def change_work_item_type(issue)
- return unless issue.changed_attributes['issue_type']
+ return unless params[:issue_type].present?
- issue_type = params[:issue_type] || ::Issue::DEFAULT_ISSUE_TYPE
- type_id = find_work_item_type_id(issue_type)
+ type_id = find_work_item_type_id(params[:issue_type])
issue.work_item_type_id = type_id
end
@@ -180,16 +179,22 @@ module Issues
end
def handle_issue_type_change(issue)
- return unless issue.previous_changes.include?('issue_type')
+ return unless issue.previous_changes.include?('work_item_type_id')
do_handle_issue_type_change(issue)
end
def do_handle_issue_type_change(issue)
- SystemNoteService.change_issue_type(issue, current_user, issue.issue_type_before_last_save)
+ old_work_item_type = ::WorkItems::Type.find(issue.work_item_type_id_before_last_save).base_type
+ SystemNoteService.change_issue_type(issue, current_user, old_work_item_type)
::IncidentManagement::IssuableEscalationStatuses::CreateService.new(issue).execute if issue.supports_escalation?
end
+
+ override :allowed_update_params
+ def allowed_update_params(params)
+ super.except(:issue_type)
+ end
end
end
diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb
index 699c5b94c53..a6fff3003ac 100644
--- a/app/services/members/creator_service.rb
+++ b/app/services/members/creator_service.rb
@@ -34,16 +34,7 @@ module Members
# @param sources [Group, Project, Array<Group>, Array<Project>, Group::ActiveRecord_Relation,
# Project::ActiveRecord_Relation] - Can't be an array of source ids because we don't know the type of source.
# @return Array<Member>
- def add_members(
- sources,
- invitees,
- access_level,
- current_user: nil,
- expires_at: nil,
- tasks_to_be_done: [],
- tasks_project_id: nil,
- ldap: nil
- ) # rubocop:disable Metrics/ParameterLists
+ def add_members(sources, invitees, access_level, **args)
return [] unless invitees.present?
sources = Array.wrap(sources) if sources.is_a?(ApplicationRecord) # For single source
@@ -51,7 +42,9 @@ module Members
Member.transaction do
sources.flat_map do |source|
# If this user is attempting to manage Owner members and doesn't have permission, do not allow
- next [] if managing_owners?(current_user, access_level) && cannot_manage_owners?(source, current_user)
+ if managing_owners?(args[:current_user], access_level) && cannot_manage_owners?(source, args[:current_user])
+ next []
+ end
emails, users, existing_members = parse_users_list(source, invitees)
@@ -59,12 +52,8 @@ module Members
source: source,
access_level: access_level,
existing_members: existing_members,
- current_user: current_user,
- expires_at: expires_at,
- tasks_to_be_done: tasks_to_be_done,
- tasks_project_id: tasks_project_id,
- ldap: ldap
- }
+ tasks_to_be_done: args[:tasks_to_be_done] || []
+ }.merge(parsed_args(args))
members = emails.map do |email|
new(invitee: email, builder: InviteMemberBuilder, **common_arguments).execute
@@ -79,26 +68,21 @@ module Members
end
end
- def add_member(
- source,
- invitee,
- access_level,
- current_user: nil,
- expires_at: nil,
- ldap: nil
- ) # rubocop:disable Metrics/ParameterLists
- add_members(
- source,
- [invitee],
- access_level,
- current_user: current_user,
- expires_at: expires_at,
- ldap: ldap
- ).first
+ def add_member(source, invitee, access_level, **args)
+ add_members(source, [invitee], access_level, **args).first
end
private
+ def parsed_args(args)
+ {
+ current_user: args[:current_user],
+ expires_at: args[:expires_at],
+ tasks_project_id: args[:tasks_project_id],
+ ldap: args[:ldap]
+ }
+ end
+
def managing_owners?(current_user, access_level)
current_user && Gitlab::Access.sym_options_with_owner[access_level] == Gitlab::Access::OWNER
end
diff --git a/app/services/members/groups/creator_service.rb b/app/services/members/groups/creator_service.rb
index dd3d44e4d96..864be01a96d 100644
--- a/app/services/members/groups/creator_service.rb
+++ b/app/services/members/groups/creator_service.rb
@@ -21,3 +21,5 @@ module Members
end
end
end
+
+Members::Groups::CreatorService.prepend_mod_with('Members::Groups::CreatorService')
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index ec8a17162ca..aaa91548d19 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -36,6 +36,10 @@ module MergeRequests
execute_external_hooks(merge_request, merge_data)
+ if action == 'open' && Feature.enabled?(:group_mentions, merge_request.project)
+ execute_group_mention_hooks(merge_request, merge_data)
+ end
+
enqueue_jira_connect_messages_for(merge_request)
end
@@ -43,6 +47,21 @@ module MergeRequests
# Implemented in EE
end
+ def execute_group_mention_hooks(merge_request, merge_data)
+ return unless merge_request.instance_of?(MergeRequest)
+
+ args = {
+ mentionable_type: 'MergeRequest',
+ mentionable_id: merge_request.id,
+ hook_data: merge_data,
+ is_confidential: false
+ }
+
+ merge_request.run_after_commit_or_now do
+ Integrations::GroupMentionWorker.perform_async(args)
+ end
+ end
+
def handle_changes(merge_request, options)
old_associations = options.fetch(:old_associations, {})
old_assignees = old_associations.fetch(:assignees, [])
diff --git a/app/services/merge_requests/cleanup_refs_service.rb b/app/services/merge_requests/cleanup_refs_service.rb
index 2094ea00160..5081655601b 100644
--- a/app/services/merge_requests/cleanup_refs_service.rb
+++ b/app/services/merge_requests/cleanup_refs_service.rb
@@ -16,7 +16,6 @@ module MergeRequests
@merge_request = merge_request
@repository = merge_request.project.repository
@ref_path = merge_request.ref_path
- @merge_ref_path = merge_request.merge_ref_path
@ref_head_sha = @repository.commit(merge_request.ref_path)&.id
@merge_ref_sha = merge_request.merge_ref_head&.id
end
@@ -42,7 +41,7 @@ module MergeRequests
private
- attr_reader :repository, :ref_path, :merge_ref_path, :ref_head_sha, :merge_ref_sha
+ attr_reader :repository, :ref_path, :ref_head_sha, :merge_ref_sha
def scheduled?
merge_request.cleanup_schedule.present? && merge_request.cleanup_schedule.scheduled_at <= Time.current
@@ -79,7 +78,7 @@ module MergeRequests
end
def delete_refs
- repository.delete_refs(ref_path, merge_ref_path)
+ merge_request.schedule_cleanup_refs
end
def update_schedule
diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb
index 1bd26f06e41..acd3bc36e1d 100644
--- a/app/services/merge_requests/merge_to_ref_service.rb
+++ b/app/services/merge_requests/merge_to_ref_service.rb
@@ -56,13 +56,6 @@ module MergeRequests
params[:first_parent_ref] || merge_request.target_branch_ref
end
- ##
- # The parameter `allow_conflicts` is a flag whether merge conflicts should be merged into diff
- # Default is false
- def allow_conflicts
- params[:allow_conflicts] || false
- end
-
def commit(cache_merge_to_ref_calls = false)
if cache_merge_to_ref_calls
Rails.cache.fetch(cache_key, expires_in: 1.day) do
@@ -79,8 +72,7 @@ module MergeRequests
branch: merge_request.target_branch,
target_ref: target_ref,
message: commit_message,
- first_parent_ref: first_parent_ref,
- allow_conflicts: allow_conflicts)
+ first_parent_ref: first_parent_ref)
rescue Gitlab::Git::PreReceiveError, Gitlab::Git::CommandError => error
raise MergeError, error.message
end
diff --git a/app/services/merge_requests/mergeability_check_batch_service.rb b/app/services/merge_requests/mergeability_check_batch_service.rb
new file mode 100644
index 00000000000..7697b596a83
--- /dev/null
+++ b/app/services/merge_requests/mergeability_check_batch_service.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class MergeabilityCheckBatchService
+ def initialize(merge_requests, user)
+ @merge_requests = merge_requests
+ @user = user
+ end
+
+ def execute
+ return unless merge_requests.present?
+
+ MergeRequests::MergeabilityCheckBatchWorker.perform_async(merge_requests.map(&:id), user&.id)
+ end
+
+ private
+
+ attr_reader :merge_requests, :user
+ end
+end
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index d6740cdf1ac..447f4f9428c 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -169,7 +169,13 @@ module MergeRequests
@outdate_service ||= Suggestions::OutdateService.new
end
+ def abort_auto_merges?(merge_request)
+ merge_request.merge_params.with_indifferent_access[:sha] != @push.newrev
+ end
+
def abort_auto_merges(merge_request)
+ return unless abort_auto_merges?(merge_request)
+
abort_auto_merge(merge_request, 'source branch was updated')
end
diff --git a/app/services/milestones/create_service.rb b/app/services/milestones/create_service.rb
index 6c3edd2e147..e8a14adc10d 100644
--- a/app/services/milestones/create_service.rb
+++ b/app/services/milestones/create_service.rb
@@ -5,11 +5,19 @@ module Milestones
def execute
milestone = parent.milestones.new(params)
+ before_create(milestone)
+
if milestone.save && milestone.project_milestone?
event_service.open_milestone(milestone, current_user)
end
milestone
end
+
+ private
+
+ def before_create(milestone)
+ milestone.check_for_spam(user: current_user, action: :create)
+ end
end
end
diff --git a/app/services/milestones/update_service.rb b/app/services/milestones/update_service.rb
index b9a12a35d31..90cb8ea9f5c 100644
--- a/app/services/milestones/update_service.rb
+++ b/app/services/milestones/update_service.rb
@@ -13,11 +13,22 @@ module Milestones
end
if params.present?
- milestone.update(params.except(:state_event))
+ milestone.assign_attributes(params.except(:state_event))
end
+ if milestone.changed?
+ before_update(milestone)
+ end
+
+ milestone.save
milestone
end
+
+ private
+
+ def before_update(milestone)
+ milestone.check_for_spam(user: current_user, action: :update)
+ end
end
end
diff --git a/app/services/namespace_settings/update_service.rb b/app/services/namespace_settings/update_service.rb
index 25525265e1c..c391320db5e 100644
--- a/app/services/namespace_settings/update_service.rb
+++ b/app/services/namespace_settings/update_service.rb
@@ -23,6 +23,12 @@ module NamespaceSettings
param_key: :new_user_signups_cap,
user_policy: :change_new_user_signups_cap
)
+ validate_settings_param_for_root_group(
+ param_key: :default_branch_protection,
+ user_policy: :update_default_branch_protection
+ )
+
+ handle_default_branch_protection unless settings_params[:default_branch_protection].blank?
if group.namespace_settings
group.namespace_settings.attributes = settings_params
@@ -33,6 +39,17 @@ module NamespaceSettings
private
+ def handle_default_branch_protection
+ # We are migrating default_branch_protection from an integer
+ # column to a jsonb column. While completing the rest of the
+ # work, we want to start translating the updates sent to the
+ # existing column into the json. Eventually, we will be updating
+ # the jsonb column directly and deprecating the original update
+ # path. Until then, we want to sync up both columns.
+ protection = Gitlab::Access::BranchProtection.new(settings_params.delete(:default_branch_protection).to_i)
+ settings_params[:default_branch_protection_defaults] = protection.to_hash
+ end
+
def validate_resource_access_token_creation_allowed_param
return if settings_params[:resource_access_token_creation_allowed].nil?
diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb
index c9375fe14a1..9465b5218b0 100644
--- a/app/services/notes/post_process_service.rb
+++ b/app/services/notes/post_process_service.rb
@@ -36,10 +36,19 @@ module Notes
return unless note.project
note_data = hook_data
- hooks_scope = note.confidential?(include_noteable: true) ? :confidential_note_hooks : :note_hooks
+ is_confidential = note.confidential?(include_noteable: true)
+ hooks_scope = is_confidential ? :confidential_note_hooks : :note_hooks
note.project.execute_hooks(note_data, hooks_scope)
note.project.execute_integrations(note_data, hooks_scope)
+
+ return unless Feature.enabled?(:group_mentions, note.project)
+
+ execute_group_mention_hooks(note, note_data, is_confidential)
+ end
+
+ def execute_group_mention_hooks(note, note_data, is_confidential)
+ Integrations::GroupMentionService.new(note, hook_data: note_data, is_confidential: is_confidential).execute
end
end
end
diff --git a/app/services/packages/debian/find_or_create_package_service.rb b/app/services/packages/debian/find_or_create_package_service.rb
deleted file mode 100644
index a9481504d2b..00000000000
--- a/app/services/packages/debian/find_or_create_package_service.rb
+++ /dev/null
@@ -1,42 +0,0 @@
-# frozen_string_literal: true
-
-module Packages
- module Debian
- class FindOrCreatePackageService < ::Packages::CreatePackageService
- include Gitlab::Utils::StrongMemoize
-
- def execute
- packages = project.packages
- .existing_debian_packages_with(name: params[:name], version: params[:version])
-
- package = packages.with_debian_codename_or_suite(params[:distribution_name]).first
-
- unless package
- package_in_other_distribution = packages.first
-
- if package_in_other_distribution
- raise ArgumentError, "Debian package #{params[:name]} #{params[:version]} exists " \
- "in distribution #{package_in_other_distribution.debian_distribution.codename}"
- end
- end
-
- package ||= create_package!(
- :debian,
- debian_publication_attributes: { distribution_id: distribution.id }
- )
-
- ServiceResponse.success(payload: { package: package })
- end
-
- private
-
- def distribution
- Packages::Debian::DistributionsFinder.new(
- project,
- codename_or_suite: params[:distribution_name]
- ).execute.last!
- end
- strong_memoize_attr :distribution
- end
- end
-end
diff --git a/app/services/packages/debian/process_changes_service.rb b/app/services/packages/debian/process_changes_service.rb
deleted file mode 100644
index eb88e7c9b59..00000000000
--- a/app/services/packages/debian/process_changes_service.rb
+++ /dev/null
@@ -1,113 +0,0 @@
-# frozen_string_literal: true
-
-module Packages
- module Debian
- class ProcessChangesService
- include ExclusiveLeaseGuard
- include Gitlab::Utils::StrongMemoize
-
- # used by ExclusiveLeaseGuard
- DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze
-
- def initialize(package_file, creator)
- @package_file = package_file
- @creator = creator
- end
-
- def execute
- # return if changes file has already been processed
- return if package_file.debian_file_metadatum&.changes?
-
- validate!
-
- try_obtain_lease do
- package_file.transaction do
- update_files_metadata
- update_changes_metadata
- end
-
- ::Packages::Debian::GenerateDistributionWorker.perform_async(:project, package.debian_distribution.id)
- end
- end
-
- private
-
- attr_reader :package_file, :creator
-
- def validate!
- raise ArgumentError, 'invalid package file' unless package_file.debian_file_metadatum
- raise ArgumentError, 'invalid package file' unless package_file.debian_file_metadatum.unknown?
- raise ArgumentError, 'invalid package file' unless metadata[:file_type] == :changes
- raise ArgumentError, 'missing Source field' unless metadata.dig(:fields, 'Source').present?
- raise ArgumentError, 'missing Version field' unless metadata.dig(:fields, 'Version').present?
- raise ArgumentError, 'missing Distribution field' unless metadata.dig(:fields, 'Distribution').present?
- end
-
- def update_files_metadata
- files.each do |filename, entry|
- file_metadata = ::Packages::Debian::ExtractMetadataService.new(entry.package_file).execute
-
- ::Packages::UpdatePackageFileService.new(entry.package_file, package_id: package.id)
- .execute
-
- # Force reload from database, as package has changed
- entry.package_file.reload_package
-
- entry.package_file.debian_file_metadatum.update!(
- file_type: file_metadata[:file_type],
- component: files[filename].component,
- architecture: file_metadata[:architecture],
- fields: file_metadata[:fields]
- )
- end
- end
-
- def update_changes_metadata
- ::Packages::UpdatePackageFileService.new(package_file, package_id: package.id)
- .execute
-
- # Force reload from database, as package has changed
- package_file.reload_package
-
- package_file.debian_file_metadatum.update!(
- file_type: metadata[:file_type],
- fields: metadata[:fields]
- )
- end
-
- def metadata
- ::Packages::Debian::ExtractChangesMetadataService.new(package_file).execute
- end
- strong_memoize_attr :metadata
-
- def files
- metadata[:files]
- end
-
- def project
- package_file.package.project
- end
-
- def package
- params = {
- name: metadata[:fields]['Source'],
- version: metadata[:fields]['Version'],
- distribution_name: metadata[:fields]['Distribution']
- }
- response = Packages::Debian::FindOrCreatePackageService.new(project, creator, params).execute
- response.payload[:package]
- end
- strong_memoize_attr :package
-
- # used by ExclusiveLeaseGuard
- def lease_key
- "packages:debian:process_changes_service:package_file:#{package_file.id}"
- end
-
- # used by ExclusiveLeaseGuard
- def lease_timeout
- DEFAULT_LEASE_TIMEOUT
- end
- end
- end
-end
diff --git a/app/services/packages/npm/create_metadata_cache_service.rb b/app/services/packages/npm/create_metadata_cache_service.rb
index 75cff5c5453..f470b9f1202 100644
--- a/app/services/packages/npm/create_metadata_cache_service.rb
+++ b/app/services/packages/npm/create_metadata_cache_service.rb
@@ -30,7 +30,7 @@ module Packages
attr_reader :package_name, :project
def metadata_content
- metadata.payload.to_json
+ ::API::Entities::NpmPackage.represent(metadata.payload).to_json
end
strong_memoize_attr :metadata_content
diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb
index 2c578760cc5..f6f2dbb8415 100644
--- a/app/services/packages/npm/create_package_service.rb
+++ b/app/services/packages/npm/create_package_service.rb
@@ -18,7 +18,7 @@ module Packages
ApplicationRecord.transaction { create_npm_package! }
end
- return error('Could not obtain package lease.', 400) unless package
+ return error('Could not obtain package lease. Please try again.', 400) unless package
package
end
@@ -40,7 +40,7 @@ module Packages
def create_npm_metadatum!(package)
package.create_npm_metadatum!(package_json: package_json)
rescue ActiveRecord::RecordInvalid => e
- if package.npm_metadatum && package.npm_metadatum.errors.added?(:package_json, 'structure is too large')
+ if package.npm_metadatum && package.npm_metadatum.errors.where(:package_json, :too_large).any? # rubocop: disable CodeReuse/ActiveRecord
Gitlab::ErrorTracking.track_exception(e, field_sizes: field_sizes_for_error_tracking)
end
diff --git a/app/services/packages/npm/deprecate_package_service.rb b/app/services/packages/npm/deprecate_package_service.rb
index 2633e9f877c..bca81ebe1de 100644
--- a/app/services/packages/npm/deprecate_package_service.rb
+++ b/app/services/packages/npm/deprecate_package_service.rb
@@ -31,7 +31,7 @@ module Packages
def packages
::Packages::Npm::PackageFinder
- .new(params['package_name'], project: project, last_of_each_version: false)
+ .new(params['package_name'], project: project)
.execute
end
diff --git a/app/services/packages/npm/generate_metadata_service.rb b/app/services/packages/npm/generate_metadata_service.rb
index 800c3ce19b4..e1795079513 100644
--- a/app/services/packages/npm/generate_metadata_service.rb
+++ b/app/services/packages/npm/generate_metadata_service.rb
@@ -98,7 +98,7 @@ module Packages
end
def package_tags
- Packages::Tag.for_package_ids(packages.last_of_each_version_ids)
+ Packages::Tag.for_package_ids(packages)
.preload_package
end
diff --git a/app/services/packages/nuget/extract_metadata_content_service.rb b/app/services/packages/nuget/extract_metadata_content_service.rb
new file mode 100644
index 00000000000..28653654018
--- /dev/null
+++ b/app/services/packages/nuget/extract_metadata_content_service.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ class ExtractMetadataContentService
+ ROOT_XPATH = '//xmlns:package/xmlns:metadata/xmlns'
+
+ XPATHS = {
+ package_name: "#{ROOT_XPATH}:id",
+ package_version: "#{ROOT_XPATH}:version",
+ authors: "#{ROOT_XPATH}:authors",
+ description: "#{ROOT_XPATH}:description",
+ license_url: "#{ROOT_XPATH}:licenseUrl",
+ project_url: "#{ROOT_XPATH}:projectUrl",
+ icon_url: "#{ROOT_XPATH}:iconUrl"
+ }.freeze
+
+ XPATH_DEPENDENCIES = "#{ROOT_XPATH}:dependencies/xmlns:dependency".freeze
+ XPATH_DEPENDENCY_GROUPS = "#{ROOT_XPATH}:dependencies/xmlns:group".freeze
+ XPATH_TAGS = "#{ROOT_XPATH}:tags".freeze
+ XPATH_PACKAGE_TYPES = "#{ROOT_XPATH}:packageTypes/xmlns:packageType".freeze
+
+ def initialize(nuspec_file_content)
+ @nuspec_file_content = nuspec_file_content
+ end
+
+ def execute
+ ServiceResponse.success(payload: extract_metadata(nuspec_file_content))
+ end
+
+ private
+
+ attr_reader :nuspec_file_content
+
+ def extract_metadata(file)
+ doc = Nokogiri::XML(file)
+
+ XPATHS.transform_values { |query| doc.xpath(query).text.presence }
+ .compact
+ .tap do |metadata|
+ metadata[:package_dependencies] = extract_dependencies(doc)
+ metadata[:package_tags] = extract_tags(doc)
+ metadata[:package_types] = extract_package_types(doc)
+ end
+ end
+
+ def extract_dependencies(doc)
+ dependencies = []
+
+ doc.xpath(XPATH_DEPENDENCIES).each do |node|
+ dependencies << extract_dependency(node)
+ end
+
+ doc.xpath(XPATH_DEPENDENCY_GROUPS).each do |group_node|
+ target_framework = group_node.attr('targetFramework')
+
+ group_node.xpath('xmlns:dependency').each do |node|
+ dependencies << extract_dependency(node).merge(target_framework: target_framework)
+ end
+ end
+
+ dependencies
+ end
+
+ def extract_dependency(node)
+ {
+ name: node.attr('id'),
+ version: node.attr('version')
+ }.compact
+ end
+
+ def extract_tags(doc)
+ tags = doc.xpath(XPATH_TAGS).text
+
+ return [] if tags.blank?
+
+ tags.split(::Packages::Tag::NUGET_TAGS_SEPARATOR)
+ end
+
+ def extract_package_types(doc)
+ doc.xpath(XPATH_PACKAGE_TYPES).map { |node| node.attr('name') }.uniq
+ end
+ end
+ end
+end
diff --git a/app/services/packages/nuget/extract_metadata_file_service.rb b/app/services/packages/nuget/extract_metadata_file_service.rb
new file mode 100644
index 00000000000..61e4892fee7
--- /dev/null
+++ b/app/services/packages/nuget/extract_metadata_file_service.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ class ExtractMetadataFileService
+ include Gitlab::Utils::StrongMemoize
+
+ ExtractionError = Class.new(StandardError)
+
+ MAX_FILE_SIZE = 4.megabytes.freeze
+
+ def initialize(package_file_id)
+ @package_file_id = package_file_id
+ end
+
+ def execute
+ raise ExtractionError, 'invalid package file' unless valid_package_file?
+
+ ServiceResponse.success(payload: nuspec_file_content)
+ end
+
+ private
+
+ attr_reader :package_file_id
+
+ def package_file
+ ::Packages::PackageFile.find_by_id(package_file_id)
+ end
+ strong_memoize_attr :package_file
+
+ def valid_package_file?
+ package_file &&
+ package_file.package&.nuget? &&
+ package_file.file.size > 0 # rubocop:disable Style/ZeroLengthPredicate
+ end
+
+ def nuspec_file_content
+ with_zip_file do |zip_file|
+ entry = zip_file.glob('*.nuspec').first
+
+ raise ExtractionError, 'nuspec file not found' unless entry
+ raise ExtractionError, 'nuspec file too big' if MAX_FILE_SIZE < entry.size
+
+ Tempfile.open("nuget_extraction_package_file_#{package_file_id}") do |file|
+ entry.extract(file.path) { true } # allow #extract to overwrite the file
+ file.unlink
+ file.read
+ end
+ rescue Zip::EntrySizeError => e
+ raise ExtractionError, "nuspec file has the wrong entry size: #{e.message}"
+ end
+ end
+
+ def with_zip_file
+ package_file.file.use_open_file do |open_file|
+ zip_file = Zip::File.new(open_file, false, true) # rubocop:disable Performance/Rubyzip
+ yield(zip_file)
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/packages/nuget/metadata_extraction_service.rb b/app/services/packages/nuget/metadata_extraction_service.rb
index 5c60a2912ae..e1ee29ef2c6 100644
--- a/app/services/packages/nuget/metadata_extraction_service.rb
+++ b/app/services/packages/nuget/metadata_extraction_service.rb
@@ -3,123 +3,30 @@
module Packages
module Nuget
class MetadataExtractionService
- include Gitlab::Utils::StrongMemoize
-
- ExtractionError = Class.new(StandardError)
-
- ROOT_XPATH = '//xmlns:package/xmlns:metadata/xmlns'
-
- XPATHS = {
- package_name: "#{ROOT_XPATH}:id",
- package_version: "#{ROOT_XPATH}:version",
- authors: "#{ROOT_XPATH}:authors",
- description: "#{ROOT_XPATH}:description",
- license_url: "#{ROOT_XPATH}:licenseUrl",
- project_url: "#{ROOT_XPATH}:projectUrl",
- icon_url: "#{ROOT_XPATH}:iconUrl"
- }.freeze
-
- XPATH_DEPENDENCIES = "#{ROOT_XPATH}:dependencies/xmlns:dependency".freeze
- XPATH_DEPENDENCY_GROUPS = "#{ROOT_XPATH}:dependencies/xmlns:group".freeze
- XPATH_TAGS = "#{ROOT_XPATH}:tags".freeze
- XPATH_PACKAGE_TYPES = "#{ROOT_XPATH}:packageTypes/xmlns:packageType".freeze
-
- MAX_FILE_SIZE = 4.megabytes.freeze
-
def initialize(package_file_id)
@package_file_id = package_file_id
end
def execute
- raise ExtractionError, 'invalid package file' unless valid_package_file?
-
- extract_metadata(nuspec_file_content)
+ ServiceResponse.success(payload: metadata)
end
private
- def package_file
- ::Packages::PackageFile.find_by_id(@package_file_id)
- end
- strong_memoize_attr :package_file
-
- def valid_package_file?
- package_file &&
- package_file.package&.nuget? &&
- package_file.file.size > 0 # rubocop:disable Style/ZeroLengthPredicate
- end
-
- def extract_metadata(file)
- doc = Nokogiri::XML(file)
-
- XPATHS.transform_values { |query| doc.xpath(query).text.presence }
- .compact
- .tap do |metadata|
- metadata[:package_dependencies] = extract_dependencies(doc)
- metadata[:package_tags] = extract_tags(doc)
- metadata[:package_types] = extract_package_types(doc)
- end
- end
-
- def extract_dependencies(doc)
- dependencies = []
-
- doc.xpath(XPATH_DEPENDENCIES).each do |node|
- dependencies << extract_dependency(node)
- end
-
- doc.xpath(XPATH_DEPENDENCY_GROUPS).each do |group_node|
- target_framework = group_node.attr("targetFramework")
-
- group_node.xpath("xmlns:dependency").each do |node|
- dependencies << extract_dependency(node).merge(target_framework: target_framework)
- end
- end
-
- dependencies
- end
-
- def extract_dependency(node)
- {
- name: node.attr('id'),
- version: node.attr('version')
- }.compact
- end
-
- def extract_package_types(doc)
- doc.xpath(XPATH_PACKAGE_TYPES).map { |node| node.attr('name') }.uniq
- end
-
- def extract_tags(doc)
- tags = doc.xpath(XPATH_TAGS).text
-
- return [] if tags.blank?
-
- tags.split(::Packages::Tag::NUGET_TAGS_SEPARATOR)
- end
+ attr_reader :package_file_id
def nuspec_file_content
- with_zip_file do |zip_file|
- entry = zip_file.glob('*.nuspec').first
-
- raise ExtractionError, 'nuspec file not found' unless entry
- raise ExtractionError, 'nuspec file too big' if MAX_FILE_SIZE < entry.size
-
- Tempfile.open("nuget_extraction_package_file_#{@package_file_id}") do |file|
- entry.extract(file.path) { true } # allow #extract to overwrite the file
- file.unlink
- file.read
- end
- rescue Zip::EntrySizeError => e
- raise ExtractionError, "nuspec file has the wrong entry size: #{e.message}"
- end
+ ExtractMetadataFileService
+ .new(package_file_id)
+ .execute
+ .payload
end
- def with_zip_file(&block)
- package_file.file.use_open_file do |open_file|
- zip_file = Zip::File.new(open_file, false, true)
- yield(zip_file)
- end
+ def metadata
+ ExtractMetadataContentService
+ .new(nuspec_file_content)
+ .execute
+ .payload
end
end
end
diff --git a/app/services/packages/nuget/update_package_from_metadata_service.rb b/app/services/packages/nuget/update_package_from_metadata_service.rb
index 8e2679db31b..d82509fff5e 100644
--- a/app/services/packages/nuget/update_package_from_metadata_service.rb
+++ b/app/services/packages/nuget/update_package_from_metadata_service.rb
@@ -145,7 +145,7 @@ module Packages
end
def metadata
- ::Packages::Nuget::MetadataExtractionService.new(@package_file.id).execute
+ ::Packages::Nuget::MetadataExtractionService.new(@package_file.id).execute.payload
end
strong_memoize_attr :metadata
diff --git a/app/services/personal_access_tokens/last_used_service.rb b/app/services/personal_access_tokens/last_used_service.rb
index 6fc3110a70b..3b075364458 100644
--- a/app/services/personal_access_tokens/last_used_service.rb
+++ b/app/services/personal_access_tokens/last_used_service.rb
@@ -24,12 +24,7 @@ module PersonalAccessTokens
return true if last_used.nil?
- if Feature.enabled?(:update_personal_access_token_usage_information_every_10_minutes) &&
- last_used <= 10.minutes.ago
- return true
- end
-
- last_used <= 1.day.ago
+ last_used <= 10.minutes.ago
end
end
end
diff --git a/app/services/personal_access_tokens/revoke_token_family_service.rb b/app/services/personal_access_tokens/revoke_token_family_service.rb
new file mode 100644
index 00000000000..547ba6c3bdc
--- /dev/null
+++ b/app/services/personal_access_tokens/revoke_token_family_service.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module PersonalAccessTokens
+ class RevokeTokenFamilyService
+ def initialize(token)
+ @token = token
+ end
+
+ def execute
+ # Despite using #update_all, there should only be a single active token.
+ # A token family is a chain of rotated tokens. Once rotated, the
+ # previous token is revoked.
+ pat_family.active.update_all(revoked: true)
+
+ ServiceResponse.success
+ end
+
+ private
+
+ attr_reader :token
+
+ def pat_family
+ # rubocop: disable CodeReuse/ActiveRecord
+ cte = Gitlab::SQL::RecursiveCTE.new(:personal_access_tokens_cte)
+ personal_access_token_table = Arel::Table.new(:personal_access_tokens)
+
+ cte << PersonalAccessToken
+ .where(personal_access_token_table[:previous_personal_access_token_id].eq(token.id))
+ cte << PersonalAccessToken
+ .from([personal_access_token_table, cte.table])
+ .where(personal_access_token_table[:previous_personal_access_token_id].eq(cte.table[:id]))
+ PersonalAccessToken.with.recursive(cte.to_arel).from(cte.alias_to(personal_access_token_table))
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+ end
+end
diff --git a/app/services/personal_access_tokens/rotate_service.rb b/app/services/personal_access_tokens/rotate_service.rb
index 64b0c5c98a9..b765aacef68 100644
--- a/app/services/personal_access_tokens/rotate_service.rb
+++ b/app/services/personal_access_tokens/rotate_service.rb
@@ -41,6 +41,7 @@ module PersonalAccessTokens
def create_token_params(token)
{ name: token.name,
+ previous_personal_access_token_id: token.id,
impersonation: token.impersonation,
scopes: token.scopes,
expires_at: Date.today + EXPIRATION_PERIOD }
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 2279ab301dc..a5c12384b59 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -58,6 +58,10 @@ module Projects
unless remove_repository(project.wiki.repository)
raise_error(s_('DeleteProject|Failed to remove wiki repository. Please try again or contact administrator.'))
end
+
+ unless remove_repository(project.design_repository)
+ raise_error(s_('DeleteProject|Failed to remove design repository. Please try again or contact administrator.'))
+ end
end
def trash_relation_repositories!
diff --git a/app/services/projects/download_service.rb b/app/services/projects/download_service.rb
index 72cb3997045..22104409199 100644
--- a/app/services/projects/download_service.rb
+++ b/app/services/projects/download_service.rb
@@ -2,7 +2,7 @@
module Projects
class DownloadService < BaseService
- WHITELIST = [
+ ALLOWLIST = [
/^[^.]+\.fogbugz.com$/
].freeze
@@ -33,7 +33,7 @@ module Projects
def valid_domain?(url)
host = URI.parse(url).host
- WHITELIST.any? { |entry| entry === host }
+ ALLOWLIST.any? { |entry| entry === host }
end
end
end
diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb
index 8c807e0016b..44cd6e9926f 100644
--- a/app/services/projects/participants_service.rb
+++ b/app/services/projects/participants_service.rb
@@ -30,6 +30,8 @@ module Projects
end
def all_members
+ return [] if Feature.enabled?(:disable_all_mention)
+
[{ username: "all", name: "All Project and Group Members", count: project_members.count }]
end
diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb
index f1c093c89b7..22a882c4648 100644
--- a/app/services/projects/prometheus/alerts/notify_service.rb
+++ b/app/services/projects/prometheus/alerts/notify_service.rb
@@ -89,7 +89,9 @@ module Projects
# AlertManagement::HttpIntegrations is complete,
# we should use use the HttpIntegration as SSOT.
# Remove with https://gitlab.com/gitlab-org/gitlab/-/issues/409734
- return false if project.alert_management_http_integrations.legacy.prometheus.any?
+ return false if project.alert_management_http_integrations
+ .for_endpoint_identifier('legacy-prometheus')
+ .any?
prometheus = project.find_or_initialize_integration('prometheus')
return false unless prometheus.manual_configuration?
diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb
index b048ec128d8..d5c8e958bbd 100644
--- a/app/services/projects/update_remote_mirror_service.rb
+++ b/app/services/projects/update_remote_mirror_service.rb
@@ -93,7 +93,7 @@ module Projects
# TODO: Support LFS sync over SSH
# https://gitlab.com/gitlab-org/gitlab/-/issues/249587
- return unless remote_mirror.url =~ %r{\Ahttps?://}i
+ return unless %r{\Ahttps?://}i.match?(remote_mirror.url)
return unless remote_mirror.password_auth?
Lfs::PushService.new(
diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb
index b5f6bff756b..d1798ce6fc0 100644
--- a/app/services/quick_actions/interpret_service.rb
+++ b/app/services/quick_actions/interpret_service.rb
@@ -188,7 +188,8 @@ module QuickActions
next unless definition
definition.execute(self, arg)
- usage_ping_tracking(definition.name, arg)
+ # summarize_diff will be removed https://gitlab.com/gitlab-org/gitlab/-/issues/407258#note_1385269274
+ usage_ping_tracking(definition.name, arg) unless definition.name == :summarize_diff
end
end
diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb
index 71314f85984..73d46a9ba70 100644
--- a/app/services/search/project_service.rb
+++ b/app/services/search/project_service.rb
@@ -4,7 +4,6 @@ module Search
class ProjectService
include Search::Filter
include Gitlab::Utils::StrongMemoize
- include ProjectsHelper
ALLOWED_SCOPES = %w(blobs issues merge_requests wiki_blobs commits notes milestones users).freeze
@@ -18,13 +17,13 @@ module Search
def execute
Gitlab::ProjectSearchResults.new(current_user,
- params[:search],
- project: project,
- repository_ref: params[:repository_ref],
- order_by: params[:order_by],
- sort: params[:sort],
- filters: filters
- )
+ params[:search],
+ project: project,
+ repository_ref: params[:repository_ref],
+ order_by: params[:order_by],
+ sort: params[:sort],
+ filters: filters
+ )
end
def allowed_scopes
@@ -33,10 +32,12 @@ module Search
def scope
strong_memoize(:scope) do
- next params[:scope] if allowed_scopes.include?(params[:scope]) && project_search_tabs?(params[:scope].to_sym)
+ search_navigation = Search::Navigation.new(user: current_user, project: project)
+ scope = params[:scope]
+ next scope if allowed_scopes.include?(scope) && search_navigation.tab_enabled_for_project?(scope.to_sym)
- allowed_scopes.find do |scope|
- project_search_tabs?(scope.to_sym)
+ allowed_scopes.find do |s|
+ search_navigation.tab_enabled_for_project?(s.to_sym)
end
end
end
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 5705e4c7cef..433e9b0da6d 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -102,16 +102,6 @@ class SearchService
end
end
- def show_elasticsearch_tabs?
- # overridden in EE
- false
- end
-
- def show_epics?
- # overridden in EE
- false
- end
-
def global_search_enabled_for_scope?
return false if show_snippets? && Feature.disabled?(:global_search_snippet_titles_tab, current_user, type: :ops)
diff --git a/app/services/service_desk/custom_emails/base_service.rb b/app/services/service_desk/custom_emails/base_service.rb
new file mode 100644
index 00000000000..62152f31012
--- /dev/null
+++ b/app/services/service_desk/custom_emails/base_service.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module ServiceDesk
+ module CustomEmails
+ class BaseService < ::BaseProjectService
+ private
+
+ def legitimate_user?
+ can?(current_user, :admin_project, project)
+ end
+
+ def setting?
+ project.service_desk_setting.present?
+ end
+
+ def credential?
+ project.service_desk_custom_email_verification.present?
+ end
+
+ def verification?
+ project.service_desk_custom_email_credential.present?
+ end
+
+ def feature_flag_enabled?
+ Feature.enabled?(:service_desk_custom_email, project)
+ end
+
+ def error_user_not_authorized
+ error_response(s_('ServiceDesk|User cannot manage project.'))
+ end
+
+ def error_feature_flag_disabled
+ error_response('Feature flag service_desk_custom_email is not enabled')
+ end
+
+ def error_response(message)
+ ServiceResponse.error(message: message)
+ end
+ end
+ end
+end
diff --git a/app/services/service_desk/custom_emails/create_service.rb b/app/services/service_desk/custom_emails/create_service.rb
new file mode 100644
index 00000000000..c3ca98a0259
--- /dev/null
+++ b/app/services/service_desk/custom_emails/create_service.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+module ServiceDesk
+ module CustomEmails
+ class CreateService < BaseService
+ def execute
+ return error_feature_flag_disabled unless feature_flag_enabled?
+ return error_user_not_authorized unless legitimate_user?
+ return error_params_missing unless has_required_params?
+ return error_custom_email_exists if credential? || verification?
+
+ return error_cannot_create_custom_email unless create_credential
+
+ if update_settings.error?
+ # We don't warp everything in a single transaction here and roll it back
+ # because ServiceDeskSettings::UpdateService uses safe_find_or_create_by!
+ rollback_credential
+ return error_cannot_create_custom_email
+ end
+
+ project.reset
+
+ # The create service may return an error response if the verification fails early.
+ # Here We want to indicate whether adding a custom email address was successful, so
+ # we don't use its response here.
+ create_verification
+
+ ServiceResponse.success
+ end
+
+ private
+
+ def update_settings
+ ServiceDeskSettings::UpdateService.new(project, current_user, create_setting_params).execute
+ end
+
+ def rollback_credential
+ ::ServiceDesk::CustomEmailCredential.find_by_project_id(project.id)&.destroy
+ end
+
+ def create_credential
+ credential = ::ServiceDesk::CustomEmailCredential.new(create_credential_params.merge(project: project))
+ credential.save
+ end
+
+ def create_verification
+ ::ServiceDesk::CustomEmailVerifications::CreateService.new(project: project, current_user: current_user).execute
+ end
+
+ def create_setting_params
+ ensure_params.permit(:custom_email)
+ end
+
+ def create_credential_params
+ ensure_params.permit(:smtp_address, :smtp_port, :smtp_username, :smtp_password)
+ end
+
+ def ensure_params
+ return params if params.is_a?(ActionController::Parameters)
+
+ ActionController::Parameters.new(params)
+ end
+
+ def has_required_params?
+ required_keys.all? { |key| params.key?(key) && params[key].present? }
+ end
+
+ def required_keys
+ %i[custom_email smtp_address smtp_port smtp_username smtp_password]
+ end
+
+ def error_custom_email_exists
+ error_response(s_('ServiceDesk|Custom email already exists'))
+ end
+
+ def error_params_missing
+ error_response(s_('ServiceDesk|Parameters missing'))
+ end
+
+ def error_cannot_create_custom_email
+ error_response(s_('ServiceDesk|Cannot create custom email'))
+ end
+ end
+ end
+end
diff --git a/app/services/service_desk/custom_emails/destroy_service.rb b/app/services/service_desk/custom_emails/destroy_service.rb
new file mode 100644
index 00000000000..1aa5994edd8
--- /dev/null
+++ b/app/services/service_desk/custom_emails/destroy_service.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module ServiceDesk
+ module CustomEmails
+ class DestroyService < BaseService
+ def execute
+ return error_feature_flag_disabled unless feature_flag_enabled?
+ return error_user_not_authorized unless legitimate_user?
+ return error_does_not_exist unless verification? || credential? || setting?
+
+ project.service_desk_custom_email_verification&.destroy
+ project.service_desk_custom_email_credential&.destroy
+ project.reset
+ project.service_desk_setting&.update!(custom_email: nil, custom_email_enabled: false)
+
+ ServiceResponse.success
+ end
+
+ private
+
+ def error_does_not_exist
+ error_response(s_('ServiceDesk|Custom email does not exist'))
+ end
+ end
+ end
+end
diff --git a/app/services/service_desk_settings/update_service.rb b/app/services/service_desk_settings/update_service.rb
index 5fe74f1f2ff..61cb6fce11f 100644
--- a/app/services/service_desk_settings/update_service.rb
+++ b/app/services/service_desk_settings/update_service.rb
@@ -8,9 +8,9 @@ module ServiceDeskSettings
params[:project_key] = nil if params[:project_key].blank?
if settings.update(params)
- success
+ ServiceResponse.success
else
- error(settings.errors.full_messages.to_sentence)
+ ServiceResponse.error(message: settings.errors.full_messages.to_sentence)
end
end
end
diff --git a/app/services/service_response.rb b/app/services/service_response.rb
index da4773ab9c7..86efc01bd30 100644
--- a/app/services/service_response.rb
+++ b/app/services/service_response.rb
@@ -56,6 +56,10 @@ class ServiceResponse
reason: reason)
end
+ def deconstruct_keys(keys)
+ to_h.slice(*keys)
+ end
+
def success?
status == :success
end
diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb
index 2ecd431fd91..e0a6d58b904 100644
--- a/app/services/spam/spam_verdict_service.rb
+++ b/app/services/spam/spam_verdict_service.rb
@@ -85,7 +85,7 @@ module Spam
# than the override verdict's priority value), then we don't need to override it.
return false if SUPPORTED_VERDICTS[verdict][:priority] > SUPPORTED_VERDICTS[OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM][:priority]
- target.allow_possible_spam?
+ target.allow_possible_spam?(user) || user.allow_possible_spam?
end
def spamcheck_client
diff --git a/app/services/system_notes/merge_requests_service.rb b/app/services/system_notes/merge_requests_service.rb
index 7758c1e8597..d71388a1552 100644
--- a/app/services/system_notes/merge_requests_service.rb
+++ b/app/services/system_notes/merge_requests_service.rb
@@ -181,3 +181,5 @@ module SystemNotes
end
end
end
+
+SystemNotes::MergeRequestsService.prepend_mod_with('SystemNotes::MergeRequestsService')
diff --git a/app/services/system_notes/time_tracking_service.rb b/app/services/system_notes/time_tracking_service.rb
index b7a2afbaf15..f9084ed67d3 100644
--- a/app/services/system_notes/time_tracking_service.rb
+++ b/app/services/system_notes/time_tracking_service.rb
@@ -147,9 +147,9 @@ module SystemNotes
readable_date = date_key.humanize.downcase
if changed_date.nil?
- "removed #{readable_date} #{changed_dates[date_key].first.to_s(:long)}"
+ "removed #{readable_date} #{changed_dates[date_key].first.to_fs(:long)}"
else
- "changed #{readable_date} to #{changed_date.to_s(:long)}"
+ "changed #{readable_date} to #{changed_date.to_fs(:long)}"
end
end
diff --git a/app/services/test_hooks/project_service.rb b/app/services/test_hooks/project_service.rb
index dcd92ac2b8c..42af65ebd57 100644
--- a/app/services/test_hooks/project_service.rb
+++ b/app/services/test_hooks/project_service.rb
@@ -32,6 +32,8 @@ module TestHooks
wiki_page_events_data
when 'releases_events'
releases_events_data
+ when 'emoji_events'
+ emoji_events_data
end
end
end
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index c55e1680bfe..1f6cf2c83c9 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -377,7 +377,7 @@ class TodoService
attributes = {
project_id: target&.project&.id,
target_id: target.id,
- target_type: target.class.name,
+ target_type: target.class.try(:polymorphic_name) || target.class.name,
commit_id: nil
}
diff --git a/app/services/users/allow_possible_spam_service.rb b/app/services/users/allow_possible_spam_service.rb
new file mode 100644
index 00000000000..d9273fe0fc1
--- /dev/null
+++ b/app/services/users/allow_possible_spam_service.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Users
+ class AllowPossibleSpamService < BaseService
+ def initialize(current_user)
+ @current_user = current_user
+ end
+
+ def execute(user)
+ custom_attribute = {
+ user_id: user.id,
+ key: UserCustomAttribute::ALLOW_POSSIBLE_SPAM,
+ value: "#{current_user.username}/#{current_user.id}+#{Time.current}"
+ }
+ UserCustomAttribute.upsert_custom_attributes([custom_attribute])
+ end
+ end
+end
diff --git a/app/services/users/ban_service.rb b/app/services/users/ban_service.rb
index 5ed31cdb778..20c34b15f15 100644
--- a/app/services/users/ban_service.rb
+++ b/app/services/users/ban_service.rb
@@ -2,6 +2,8 @@
module Users
class BanService < BannedUserBaseService
+ extend ::Gitlab::Utils::Override
+
private
def update_user(user)
@@ -15,6 +17,11 @@ module Users
def action
:ban
end
+
+ override :track_event
+ def track_event(user)
+ experiment(:phone_verification_for_low_risk_users, user: user).track(:banned)
+ end
end
end
diff --git a/app/services/users/banned_user_base_service.rb b/app/services/users/banned_user_base_service.rb
index 74c10581a6e..cec351904a9 100644
--- a/app/services/users/banned_user_base_service.rb
+++ b/app/services/users/banned_user_base_service.rb
@@ -12,6 +12,7 @@ module Users
if update_user(user)
log_event(user)
+ track_event(user)
success
else
messages = user.errors.full_messages
@@ -23,6 +24,9 @@ module Users
attr_reader :current_user
+ # Overridden in Users::BanService
+ def track_event(_); end
+
def state_error(user)
error(_("You cannot %{action} %{state} users." % { action: action.to_s, state: user.state }), :forbidden)
end
diff --git a/app/services/users/disallow_possible_spam_service.rb b/app/services/users/disallow_possible_spam_service.rb
new file mode 100644
index 00000000000..e31ba7ddff0
--- /dev/null
+++ b/app/services/users/disallow_possible_spam_service.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Users
+ class DisallowPossibleSpamService < BaseService
+ def initialize(current_user)
+ @current_user = current_user
+ end
+
+ def execute(user)
+ user.custom_attributes.by_key(UserCustomAttribute::ALLOW_POSSIBLE_SPAM).delete_all
+ end
+ end
+end
diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb
index 9ab6fcc9832..6837bc47035 100644
--- a/app/services/web_hook_service.rb
+++ b/app/services/web_hook_service.rb
@@ -189,6 +189,7 @@ class WebHookService
'Content-Type' => 'application/json',
'User-Agent' => "GitLab/#{Gitlab::VERSION}",
Gitlab::WebHooks::GITLAB_EVENT_HEADER => self.class.hook_to_event(hook_name),
+ Gitlab::WebHooks::GITLAB_UUID_HEADER => SecureRandom.uuid,
Gitlab::WebHooks::GITLAB_INSTANCE_HEADER => Gitlab.config.gitlab.base_url
}
diff --git a/app/services/work_items/export_csv_service.rb b/app/services/work_items/export_csv_service.rb
index ee20a2832ce..74bc1f526bf 100644
--- a/app/services/work_items/export_csv_service.rb
+++ b/app/services/work_items/export_csv_service.rb
@@ -28,7 +28,7 @@ module WorkItems
'Type' => ->(work_item) { work_item.work_item_type.name },
'Author' => 'author_name',
'Author Username' => ->(work_item) { work_item.author.username },
- 'Created At (UTC)' => ->(work_item) { work_item.created_at.to_s(:csv) }
+ 'Created At (UTC)' => ->(work_item) { work_item.created_at.to_fs(:csv) }
}
end
diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb
index f947f70985c..87a624ddb60 100644
--- a/app/uploaders/file_uploader.rb
+++ b/app/uploaders/file_uploader.rb
@@ -165,7 +165,7 @@ class FileUploader < GitlabUploader
def secret
@secret ||= self.class.generate_secret
- raise InvalidSecret unless @secret =~ VALID_SECRET_PATTERN
+ raise InvalidSecret unless VALID_SECRET_PATTERN.match?(@secret)
@secret
end
diff --git a/app/validators/abstract_path_validator.rb b/app/validators/abstract_path_validator.rb
index ff390a624c5..29620543a0f 100644
--- a/app/validators/abstract_path_validator.rb
+++ b/app/validators/abstract_path_validator.rb
@@ -21,7 +21,7 @@ class AbstractPathValidator < ActiveModel::EachValidator
end
def validate_each(record, attribute, value)
- unless value =~ self.class.format_regex
+ unless self.class.format_regex.match?(value)
record.errors.add(attribute, self.class.format_error_message)
return
end
diff --git a/app/validators/cluster_name_validator.rb b/app/validators/cluster_name_validator.rb
index 79c9c67ae58..527116ba69b 100644
--- a/app/validators/cluster_name_validator.rb
+++ b/app/validators/cluster_name_validator.rb
@@ -16,7 +16,7 @@ class ClusterNameValidator < ActiveModel::EachValidator
record.errors.add(attribute, " is invalid syntax")
end
- unless value =~ Gitlab::Regex.kubernetes_namespace_regex
+ unless Gitlab::Regex.kubernetes_namespace_regex.match(value)
record.errors.add(attribute, Gitlab::Regex.kubernetes_namespace_regex_message)
end
end
diff --git a/app/validators/cron_validator.rb b/app/validators/cron_validator.rb
index 91b9cfcccc4..c12b29410d4 100644
--- a/app/validators/cron_validator.rb
+++ b/app/validators/cron_validator.rb
@@ -1,16 +1,16 @@
# frozen_string_literal: true
class CronValidator < ActiveModel::EachValidator
- ATTRIBUTE_WHITELIST = %i[cron freeze_start freeze_end].freeze
+ ATTRIBUTE_ALLOWLIST = %i[cron freeze_start freeze_end].freeze
- NonWhitelistedAttributeError = Class.new(StandardError)
+ NonAllowlistedAttributeError = Class.new(StandardError)
def validate_each(record, attribute, value)
- if ATTRIBUTE_WHITELIST.include?(attribute)
+ if ATTRIBUTE_ALLOWLIST.include?(attribute)
cron_parser = Gitlab::Ci::CronParser.new(record.public_send(attribute), record.cron_timezone) # rubocop:disable GitlabSecurity/PublicSend
record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_valid?
else
- raise NonWhitelistedAttributeError, "Non-whitelisted attribute"
+ raise NonAllowlistedAttributeError, "Non-allowlisted attribute"
end
end
end
diff --git a/app/validators/devise_email_validator.rb b/app/validators/devise_email_validator.rb
index 6ca921ca7fa..b91cfe23f08 100644
--- a/app/validators/devise_email_validator.rb
+++ b/app/validators/devise_email_validator.rb
@@ -31,6 +31,6 @@ class DeviseEmailValidator < ActiveModel::EachValidator
end
def validate_each(record, attribute, value)
- record.errors.add(attribute, :invalid) unless value =~ options[:regexp]
+ record.errors.add(attribute, :invalid) unless options[:regexp].match?(value)
end
end
diff --git a/app/validators/json_schemas/default_branch_protection_defaults.json b/app/validators/json_schemas/default_branch_protection_defaults.json
index bd2945c08fb..d93527ad0a4 100644
--- a/app/validators/json_schemas/default_branch_protection_defaults.json
+++ b/app/validators/json_schemas/default_branch_protection_defaults.json
@@ -62,14 +62,8 @@
"code_owner_approval_required": {
"type": "boolean"
},
- "merge_access_level": {
- "type": "integer"
- },
- "push_access_level": {
- "type": "integer"
- },
- "unprotect_access_level": {
- "type": "integer"
+ "developer_can_initial_push": {
+ "type": "boolean"
}
},
"additionalProperties": false
diff --git a/app/validators/json_schemas/organization_settings.json b/app/validators/json_schemas/organization_settings.json
new file mode 100644
index 00000000000..350ce7d9066
--- /dev/null
+++ b/app/validators/json_schemas/organization_settings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "Settings for Organizations",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "restricted_visibility_levels": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ }
+ }
+}
diff --git a/app/validators/json_schemas/scan_result_policy_vulnerability_attributes.json b/app/validators/json_schemas/scan_result_policy_vulnerability_attributes.json
new file mode 100644
index 00000000000..e0051179a1d
--- /dev/null
+++ b/app/validators/json_schemas/scan_result_policy_vulnerability_attributes.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "Scan result policy vulnerability_attributes",
+ "type": "object",
+ "properties": {
+ "false_positive": {
+ "type": "boolean"
+ },
+ "fix_available": {
+ "type": "boolean"
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json
index fb6b80e0725..9cfb62d4439 100644
--- a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json
+++ b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json
@@ -32,12 +32,12 @@
},
{
"field": "SEARCH_MAX_DEPTH",
- "label": "Search maximum depth",
+ "label": "Search Maximum Depth",
"type": "string",
"default_value": "",
"value": "",
"size": "SMALL",
- "description": "Maximum depth of language and framework detection"
+ "description": "Specifies the number of directory levels to be included in the repository search phase during SAST analysis. SAST scanner searches through the repository to detect the programming languages used and selects the corresponding analyzers. After that, the entire repository is analyzed."
}
],
"analyzers": [
@@ -80,56 +80,72 @@
"label": "Kubesec",
"enabled": true,
"description": "Kubernetes manifests, Helm Charts",
- "variables": []
+ "variables": [
+
+ ]
},
{
"name": "nodejs-scan",
"label": "Node.js Scan",
"enabled": true,
"description": "Node.js",
- "variables": []
+ "variables": [
+
+ ]
},
{
"name": "phpcs-security-audit",
"label": "PHP Security Audit",
"enabled": true,
"description": "PHP",
- "variables": []
+ "variables": [
+
+ ]
},
{
"name": "pmd-apex",
"label": "PMD APEX",
"enabled": true,
"description": "Apex (Salesforce)",
- "variables": []
+ "variables": [
+
+ ]
},
{
"name": "security-code-scan",
"label": "Security Code Scan",
"enabled": true,
"description": ".NET Core, .NET Framework",
- "variables": []
+ "variables": [
+
+ ]
},
{
"name": "semgrep",
"label": "Semgrep",
"enabled": true,
"description": "Multi-language scanning",
- "variables": []
+ "variables": [
+
+ ]
},
{
"name": "sobelow",
"label": "Sobelow",
"enabled": true,
"description": "Elixir (Phoenix)",
- "variables": []
+ "variables": [
+
+ ]
},
{
"name": "spotbugs",
"label": "Spotbugs",
"enabled": true,
"description": "Groovy, Java, Scala",
- "variables": []
+ "variables": [
+
+ ]
}
]
-} \ No newline at end of file
+}
diff --git a/app/validators/line_code_validator.rb b/app/validators/line_code_validator.rb
index a351180790e..e1abccc1dff 100644
--- a/app/validators/line_code_validator.rb
+++ b/app/validators/line_code_validator.rb
@@ -7,7 +7,7 @@ class LineCodeValidator < ActiveModel::EachValidator
PATTERN = /\A[a-z0-9]+_\d+_\d+\z/.freeze
def validate_each(record, attribute, value)
- unless value =~ PATTERN
+ unless PATTERN.match?(value)
record.errors.add(attribute, "must be a valid line code")
end
end
diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml
index d29fa9c5b85..af67ed28309 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -51,11 +51,11 @@
= f.text_field :user_default_internal_regex, placeholder: _('Regex pattern'), class: 'form-control gl-form-input gl-mt-2'
.help-block
= _('Specify an email address regex pattern to identify default internal users.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/external_users', anchor: 'set-a-new-user-to-external'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/external_users', anchor: 'set-a-new-user-to-external'), target: '_blank', rel: 'noopener noreferrer'
- unless Gitlab.com?
.form-group
= f.label :deactivate_dormant_users, _('Dormant users'), class: 'label-bold'
- - dormant_users_help_link = help_page_path('user/admin_area/moderate_users', anchor: 'automatically-deactivate-dormant-users')
+ - dormant_users_help_link = help_page_path('administration/moderate_users', anchor: 'automatically-deactivate-dormant-users')
- dormant_users_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: dormant_users_help_link }
= f.gitlab_ui_checkbox_component :deactivate_dormant_users, _('Deactivate dormant users after a period of inactivity'), help_text: _('Users can reactivate their account by signing in. %{link_start}Learn more.%{link_end}').html_safe % { link_start: dormant_users_help_link_start, link_end: '</a>'.html_safe }
.form-group
diff --git a/app/views/admin/application_settings/_ai_access.html.haml b/app/views/admin/application_settings/_ai_access.html.haml
index 41b0a08128e..97f46adef51 100644
--- a/app/views/admin/application_settings/_ai_access.html.haml
+++ b/app/views/admin/application_settings/_ai_access.html.haml
@@ -12,8 +12,7 @@
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
%p
- = s_('CodeSuggestionsSM|Enable Code Suggestion for users of this GitLab instance.')
- = link_to sprite_icon('question-o'), code_suggestions_docs_url, target: '_blank', class: 'has-tooltip', title: _('More information')
+ = code_suggestions_description
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-ai-access-settings'), html: { class: 'fieldset-form', id: 'ai-access-settings' } do |f|
@@ -22,7 +21,7 @@
%fieldset
.form-group
= f.gitlab_ui_checkbox_component :instance_level_code_suggestions_enabled,
- s_('CodeSuggestionsSM|Turn on Code Suggestions for this instance. By turning on this feature, you:'),
+ s_('CodeSuggestionsSM|Enable Code Suggestions for this instance %{beta}').html_safe % { beta: gl_badge_tag(_('Beta'), variant: :neutral, size: :sm) },
help_text: code_suggestions_agreement
= f.label :ai_access_token, token_label, class: 'label-bold'
= f.password_field :ai_access_token, value: token_value, autocomplete: 'on', class: 'form-control gl-form-input', aria: { describedby: 'code_suggestions_token_explanation' }
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
index 0c9d5a5a8df..0125c83dc72 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -28,13 +28,13 @@
= f.number_field :max_artifacts_size, class: 'form-control gl-form-input'
.form-text.text-muted
= _("The maximum file size for job artifacts.")
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size')
+ = link_to _('Learn more.'), help_page_path('administration/settings/continuous_integration', anchor: 'maximum-artifacts-size')
.form-group
= f.label :default_artifacts_expire_in, _('Default artifacts expiration'), class: 'label-bold'
= f.text_field :default_artifacts_expire_in, class: 'form-control gl-form-input'
.form-text.text-muted
= html_escape(_("Set the default expiration time for job artifacts in all projects. Set to %{code_open}0%{code_close} to never expire artifacts by default. If no unit is written, it defaults to seconds. For example, these are all equivalent: %{code_open}3600%{code_close}, %{code_open}60 minutes%{code_close}, or %{code_open}one hour%{code_close}.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration')
+ = link_to _('Learn more.'), help_page_path('administration/settings/continuous_integration', anchor: 'default-artifacts-expiration')
.form-group
= f.gitlab_ui_checkbox_component :keep_latest_artifact, s_('AdminSettings|Keep the latest artifacts for all jobs in the latest successful pipelines'), help_text: s_('AdminSettings|The latest artifacts for all jobs in the most recent successful pipelines in each project are stored and do not expire.')
.form-group
@@ -42,7 +42,7 @@
= f.text_field :archive_builds_in_human_readable, class: 'form-control gl-form-input'
.form-text.text-muted
= html_escape(_("Jobs older than the configured time are considered expired and are archived. Archived jobs can no longer be retried. Leave empty to never archive jobs automatically. The default unit is in days, but you can use other units, for example %{code_open}15 days%{code_close}, %{code_open}1 month%{code_close}, %{code_open}2 years%{code_close}. Minimum value is 1 day.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'archive-jobs')
+ = link_to _('Learn more.'), help_page_path('administration/settings/continuous_integration', anchor: 'archive-jobs')
.form-group
= f.gitlab_ui_checkbox_component :protected_ci_variables, s_('AdminSettings|Protect CI/CD variables by default'), help_text: s_('AdminSettings|New CI/CD variables in projects and groups default to protected.')
.form-group
diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml
index 153600f1299..f8bd5b68431 100644
--- a/app/views/admin/application_settings/_diff_limits.html.haml
+++ b/app/views/admin/application_settings/_diff_limits.html.haml
@@ -9,7 +9,7 @@
= _("Diff files surpassing this limit will be presented as 'too large' and won't be expandable.")
= link_to sprite_icon('question-o'),
- help_page_path('user/admin_area/diff_limits',
+ help_page_path('administration/diff_limits',
anchor: 'diff-limits-administration')
= f.label :diff_max_files, _('Maximum files in a diff'), class: 'label-light'
@@ -18,7 +18,7 @@
= _("Diff files surpassing this limit will be presented as 'too large' and won't be expandable.")
= link_to sprite_icon('question-o'),
- help_page_path('user/admin_area/diff_limits',
+ help_page_path('administration/diff_limits',
anchor: 'diff-limits-administration')
= f.label :diff_max_lines, _('Maximum lines in a diff'), class: 'label-light'
@@ -27,6 +27,6 @@
= _("Diff files surpassing this limit will be presented as 'too large' and won't be expandable.")
= link_to sprite_icon('question-o'),
- help_page_path('user/admin_area/diff_limits',
+ help_page_path('administration/diff_limits',
anchor: 'diff-limits-administration')
= f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml
index 80a7d3607ef..2f31eb5f6d1 100644
--- a/app/views/admin/application_settings/_email.html.haml
+++ b/app/views/admin/application_settings/_email.html.haml
@@ -10,7 +10,7 @@
= f.label :commit_email_hostname, _('Custom hostname (for private commit emails)'), class: 'label-bold'
= f.text_field :commit_email_hostname, class: 'form-control gl-form-input'
.form-text.text-muted
- - commit_email_hostname_docs_link = link_to _('Learn more'), help_page_path('user/admin_area/settings/email.md', anchor: 'custom-hostname-for-private-commit-emails'), target: '_blank', rel: 'noopener noreferrer'
+ - commit_email_hostname_docs_link = link_to _('Learn more'), help_page_path('administration/settings/email.md', anchor: 'custom-hostname-for-private-commit-emails'), target: '_blank', rel: 'noopener noreferrer'
= _("Hostname used in private commit emails. %{learn_more}").html_safe % { learn_more: commit_email_hostname_docs_link }
= render_if_exists 'admin/application_settings/email_additional_text_setting', form: f
diff --git a/app/views/admin/application_settings/_error_tracking.html.haml b/app/views/admin/application_settings/_error_tracking.html.haml
index aa42cd99e89..b57371286d5 100644
--- a/app/views/admin/application_settings/_error_tracking.html.haml
+++ b/app/views/admin/application_settings/_error_tracking.html.haml
@@ -20,9 +20,9 @@
%p.text-secondary
= s_("ErrorTracking|Access token is %{token_in_code_tag}").html_safe % { token_in_code_tag: content_tag(:code, Gitlab::CurrentSettings.error_tracking_access_token, id: 'error-tracking-access-token') }
.form-inline
- = button_to _("Reset error tracking access token"), reset_error_tracking_access_token_admin_application_settings_path,
- method: :put, class: 'gl-button btn btn-danger btn-sm',
- data: { confirm: _('Are you sure you want to reset the error tracking access token?') }
+ - reset_url = reset_error_tracking_access_token_admin_application_settings_url
+ = render Pajamas::ButtonComponent.new(method: :put, href: reset_url, variant: :danger, size: :small, button_options: { data: { confirm: _('Are you sure you want to reset the error tracking access token?') }}) do
+ = _("Reset error tracking access token")
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-error-tracking-settings'), html: { class: 'fieldset-form', id: 'error-tracking-settings' } do |f|
= form_errors(@application_setting) if expanded
diff --git a/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml b/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml
new file mode 100644
index 00000000000..4bd44b922fa
--- /dev/null
+++ b/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml
@@ -0,0 +1,19 @@
+%section.settings.no-animate#js-gitlab-shell-operation-limits-settings{ class: ('expanded' if expanded_by_default?), 'data-testid': 'gitlab-shell-operation-limits' }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
+ = s_('ShellOperations|Git SSH operations rate limit')
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p
+ = s_('ShellOperations|Limit the number of Git operations a user can perform per minute, per repository.')
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/rate_limits_on_git_ssh_operations.md'), target: '_blank', rel: 'noopener noreferrer'
+ .settings-content
+ = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-gitlab-shell-operation-limits-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :gitlab_shell_operation_limit, s_('ShellOperations|Maximum number of Git operations per minute'), class: 'gl-font-bold'
+ = f.number_field :gitlab_shell_operation_limit, class: 'form-control gl-form-input'
+
+ = f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml
index e76a83662af..9509806fc41 100644
--- a/app/views/admin/application_settings/_help_page.html.haml
+++ b/app/views/admin/application_settings/_help_page.html.haml
@@ -18,7 +18,7 @@
.form-group
= f.label :help_page_documentation_base_url, _('Documentation pages URL'), class: 'gl-font-weight-bold'
= f.text_field :help_page_documentation_base_url, class: 'form-control gl-form-input', placeholder: 'https://docs.gitlab.com'
- - docs_link_url = help_page_path('user/admin_area/settings/help_page', anchor: 'destination-requirements')
+ - docs_link_url = help_page_path('administration/settings/help_page', anchor: 'destination-requirements')
- docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url }
%span.form-text.text-muted#support_help_block= html_escape(_('Requests for pages at %{code_start}%{help_text_url}%{code_end} redirect to the URL. The destination must meet certain requirements. %{docs_link_start}Learn more.%{docs_link_end}')) % { code_start: '<code>'.html_safe, help_text_url: help_url, code_end: '</code>'.html_safe, docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe }
= f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml
index 669c47bafba..19d321ca205 100644
--- a/app/views/admin/application_settings/_localization.html.haml
+++ b/app/views/admin/application_settings/_localization.html.haml
@@ -7,7 +7,7 @@
= f.select :first_day_of_week, first_day_of_week_choices, {}, class: 'form-control'
.form-text.text-muted
= _('Default first day of the week in calendars and date pickers.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/index.md', anchor: 'default-first-day-of-the-week'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/index.md', anchor: 'default-first-day-of-the-week'), target: '_blank', rel: 'noopener noreferrer'
.form-group
= f.label :time_tracking, _('Time tracking'), class: 'label-bold'
diff --git a/app/views/admin/application_settings/_runner_registrars_form.html.haml b/app/views/admin/application_settings/_runner_registrars_form.html.haml
index 53832e93ed2..b112c273aad 100644
--- a/app/views/admin/application_settings/_runner_registrars_form.html.haml
+++ b/app/views/admin/application_settings/_runner_registrars_form.html.haml
@@ -7,7 +7,7 @@
= s_('Runners|Runner version management')
%span.form-text.gl-mb-3.gl-mt-0
- help_text = s_('Runners|Official runner version data is periodically fetched from GitLab.com to determine whether the runners need upgrades.')
- - learn_more_link = link_to _('Learn more.'), help_page_path('ci/runners/configure_runners.md', anchor: 'determine-which-runners-need-to-be-upgraded'), target: '_blank', rel: 'noopener noreferrer'
+ - learn_more_link = link_to _('Learn more.'), help_page_path('ci/runners/runners_scope.md', anchor: 'determine-which-runners-need-to-be-upgraded'), target: '_blank', rel: 'noopener noreferrer'
= f.gitlab_ui_checkbox_component :update_runner_versions_enabled,
s_('Runners|Fetch GitLab Runner release version data from GitLab.com'),
help_text: '%{help_text} %{learn_more_link}'.html_safe % { help_text: help_text, learn_more_link: learn_more_link }
@@ -16,7 +16,7 @@
= s_('Runners|Runner registration')
%span.form-text.gl-mb-3.gl-mt-0
= s_('Runners|If both settings are disabled, new runners cannot be registered.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer'
= hidden_field_tag "application_setting[valid_runner_registrars][]", nil
- ApplicationSetting::VALID_RUNNER_REGISTRAR_TYPES.each do |type|
= f.gitlab_ui_checkbox_component :valid_runner_registrars, s_("Runners|Members of the %{type} can register runners") % { type: type },
diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml
index 85841059c5e..5518122b5cf 100644
--- a/app/views/admin/application_settings/_signin.html.haml
+++ b/app/views/admin/application_settings/_signin.html.haml
@@ -32,7 +32,7 @@
= f.label :admin_mode, _('Admin mode'), class: 'label-bold'
= sprite_icon('lock', css_class: 'gl-icon')
- help_text = _('Require additional authentication for administrative tasks.')
- - help_link = link_to _('Learn more.'), help_page_path('user/admin_area/settings/sign_in_restrictions', anchor: 'admin-mode'), target: '_blank', rel: 'noopener noreferrer'
+ - help_link = link_to _('Learn more.'), help_page_path('administration/settings/sign_in_restrictions', anchor: 'admin-mode'), target: '_blank', rel: 'noopener noreferrer'
= f.gitlab_ui_checkbox_component :admin_mode,
_('Enable admin mode'),
help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link }
diff --git a/app/views/admin/application_settings/_slack.html.haml b/app/views/admin/application_settings/_slack.html.haml
index 69a5e284b4c..e4f46fdf7f2 100644
--- a/app/views/admin/application_settings/_slack.html.haml
+++ b/app/views/admin/application_settings/_slack.html.haml
@@ -1,33 +1,66 @@
-- return unless Gitlab.dev_or_test_env? || Gitlab.com?
-
+- gitlab_com = Gitlab.com?
- expanded = integration_expanded?('slack_app_')
+
%section.settings.as-slack.no-animate#js-slack-settings{ class: ('expanded' if expanded) }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
- = _('Slack application')
+ = s_('Integrations|GitLab for Slack app')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
%p
- = _('Slack integration allows you to interact with GitLab via slash commands in a chat window.')
+ = s_('SlackIntegration|Configure your GitLab for Slack app.')
+ = link_to(_('Learn more.'), help_page_path('user/admin_area/settings/slack_app'), target: '_blank', rel: 'noopener noreferrer')
+
.settings-content
+ - unless gitlab_com
+ %h5
+ = s_('SlackIntegration|Step 1: Create your GitLab for Slack app')
+ %p
+ = s_('SlackIntegration|You must do this step only once.')
+ %p
+ = render Pajamas::ButtonComponent.new(href: slack_app_manifest_share_admin_application_settings_path) do
+ = s_("SlackIntegration|Create Slack app")
+ %hr
+ %h5
+ = s_('SlackIntegration|Step 2: Configure the app settings')
+ %p
+ - tag_pair_slack_apps = tag_pair(link_to('', 'https://api.slack.com/apps', target: '_blank', rel: 'noopener noreferrer'), :link_start, :link_end)
+ - tag_pair_strong = tag_pair(tag.strong, :strong_open, :strong_close)
+ = safe_format(s_('SlackIntegration|Copy the %{link_start}settings%{link_end} from %{strong_open}%{settings_heading}%{strong_close} in your GitLab for Slack app.'), tag_pair_slack_apps, tag_pair_strong, settings_heading: 'App Credentials')
+ = link_to(_('Learn more.'), help_page_path('user/admin_area/settings/slack_app', anchor: 'configure-the-settings'), target: '_blank', rel: 'noopener noreferrer')
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-slack-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting) if expanded
-
%fieldset
.form-group
- = f.gitlab_ui_checkbox_component :slack_app_enabled, s_('ApplicationSettings|Enable Slack application'),
- help_text: s_('ApplicationSettings|This option is only available on GitLab.com')
+ = f.gitlab_ui_checkbox_component :slack_app_enabled, s_('ApplicationSettings|Enable GitLab for Slack app')
.form-group
= f.label :slack_app_id, s_('SlackIntegration|Client ID'), class: 'label-bold'
= f.text_field :slack_app_id, class: 'form-control gl-form-input'
.form-group
= f.label :slack_app_secret, s_('SlackIntegration|Client secret'), class: 'label-bold'
= f.text_field :slack_app_secret, class: 'form-control gl-form-input'
+ .form-text.text-muted
+ = s_('SlackIntegration|Used for authenticating OAuth requests from the GitLab for Slack app.')
.form-group
= f.label :slack_app_signing_secret, s_('SlackIntegration|Signing secret'), class: 'label-bold'
= f.text_field :slack_app_signing_secret, class: 'form-control gl-form-input'
+ .form-text.text-muted
+ = s_('SlackIntegration|Used for authenticating API requests from the GitLab for Slack app.')
.form-group
= f.label :slack_app_verification_token, s_('SlackIntegration|Verification token'), class: 'label-bold'
= f.text_field :slack_app_verification_token, class: 'form-control gl-form-input'
-
+ .form-text.text-muted
+ = s_('SlackIntegration|Used only for authenticating slash commands from the GitLab for Slack app. This method of authentication is deprecated by Slack.')
= f.submit _('Save changes'), pajamas_button: true
+
+ - unless gitlab_com
+ %hr
+ %h5
+ = s_('SlackIntegration|Update your Slack app')
+ %p
+ = s_('SlackIntegration|When GitLab releases new features for the GitLab for Slack app, you might have to manually update your copy to use the new features.')
+ = link_to(_('Learn more.'), help_page_path('user/admin_area/settings/slack_app', anchor: 'update-the-gitlab-for-slack-app'), target: '_blank', rel: 'noopener noreferrer')
+ %p
+ = render Pajamas::ButtonComponent.new(href: slack_app_manifest_download_admin_application_settings_path, icon: 'download') do
+ = s_("SlackIntegration|Download latest manifest file")
+
diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml
index 2eda3eab8c7..91cd6fe7ca0 100644
--- a/app/views/admin/application_settings/_usage.html.haml
+++ b/app/views/admin/application_settings/_usage.html.haml
@@ -7,13 +7,13 @@
%fieldset
.form-group
- - help_link_start = link_start % { url: help_page_path('user/admin_area/settings/usage_statistics', anchor: 'version-check') }
+ - help_link_start = link_start % { url: help_page_path('administration/settings/usage_statistics', anchor: 'version-check') }
= f.gitlab_ui_checkbox_component :version_check_enabled, _('Enable version check'),
help_text: _("GitLab informs you if a new version is available. %{link_start}What information does GitLab Inc. collect?%{link_end}").html_safe % { link_start: help_link_start, link_end: link_end }
.form-group
- can_be_configured = @application_setting.usage_ping_can_be_configured?
- service_ping_link_start = link_start % { url: help_page_path('development/service_ping/index') }
- - deactivating_service_ping_link_start = link_start % { url: help_page_path('user/admin_area/settings/usage_statistics', anchor: 'disable-usage-statistics-with-the-configuration-file') }
+ - deactivating_service_ping_link_start = link_start % { url: help_page_path('administration/settings/usage_statistics', anchor: 'disable-usage-statistics-with-the-configuration-file') }
- usage_ping_help_text = s_('AdminSettings|To help improve GitLab and its user experience, GitLab periodically collects usage information. %{link_start}What information is shared with GitLab Inc.?%{link_end}').html_safe % { link_start: service_ping_link_start, link_end: link_end }
- disabled_help_text = s_('AdminSettings|Service ping is disabled in your configuration file, and cannot be enabled through this form. For more information, see the documentation on %{link_start}deactivating service ping%{link_end}.').html_safe % { link_start: deactivating_service_ping_link_start, link_end: link_end }
= f.gitlab_ui_checkbox_component :usage_ping_enabled, s_('AdminSettings|Enable Service Ping'),
@@ -28,7 +28,7 @@
.form-group
- usage_ping_enabled = @application_setting.usage_ping_enabled?
- label = s_('AdminSettings|Enable Registration Features')
- - label_link = link_to sprite_icon('question-o'), help_page_path('user/admin_area/settings/usage_statistics', anchor: 'registration-features-program')
+ - label_link = link_to sprite_icon('question-o'), help_page_path('administration/settings/usage_statistics', anchor: 'registration-features-program')
- help_text = usage_ping_enabled ? s_('AdminSettings|You can enable Registration Features because Service Ping is enabled. To continue using Registration Features in the future, you will also need to register with GitLab via a new cloud licensing service.') : s_('AdminSettings|To enable Registration Features, first enable Service Ping.')
= f.gitlab_ui_checkbox_component :usage_ping_features_enabled?, '%{label} %{label_link}'.html_safe % { label: label, label_link: label_link },
help_text: '<span id="service_ping_features_helper_text">%{help_text}</span>'.html_safe % { help_text: help_text },
@@ -37,7 +37,7 @@
.form-text.gl-text-gray-500.gl-pl-6
%p.gl-mb-3= s_('AdminSettings|Registration Features include:')
- email_from_gitlab_path = help_page_path('user/admin_area/email_from_gitlab')
- - repo_size_limit_path = help_page_path('user/admin_area/settings/account_and_limit_settings', anchor: 'repository-size-limit')
+ - repo_size_limit_path = help_page_path('administration/settings/account_and_limit_settings', anchor: 'repository-size-limit')
- restrict_ip_path = help_page_path('user/group/access_and_permissions', anchor: 'restrict-group-access-by-ip-address')
- email_from_gitlab_link = link_start % { url: email_from_gitlab_path }
- repo_size_limit_link = link_start % { url: repo_size_limit_path }
diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml
index 1b0e974a0ca..fb5c320268e 100644
--- a/app/views/admin/application_settings/appearances/_form.html.haml
+++ b/app/views/admin/application_settings/appearances/_form.html.haml
@@ -3,9 +3,8 @@
= gitlab_ui_form_for @appearance, url: admin_application_settings_appearances_path, html: { class: 'gl-mt-3' } do |f|
= form_errors(@appearance)
-
.row
- .col-lg-4.profile-settings-sidebar
+ .col-lg-4
%h4.gl-mt-0= _('Navigation bar')
.col-lg-8
@@ -25,7 +24,7 @@
= _('Maximum file size is 1MB. Pages are optimized for a 24px tall header logo')
%hr
.row
- .col-lg-4.profile-settings-sidebar
+ .col-lg-4
%h4.gl-mt-0 Favicon
.col-lg-8
@@ -50,7 +49,7 @@
%hr
.row
- .col-lg-4.profile-settings-sidebar
+ .col-lg-4
%h4.gl-mt-0= _('Sign in/Sign up pages')
.col-lg-8
@@ -79,7 +78,7 @@
%hr
.row
- .col-lg-4.profile-settings-sidebar
+ .col-lg-4
%h4.gl-mt-0= _('Progressive Web App (PWA)')
.col-lg-8
@@ -111,7 +110,7 @@
%hr
.row
- .col-lg-4.profile-settings-sidebar
+ .col-lg-4
%h4.gl-mt-0= _('New project pages')
.col-lg-8
@@ -124,7 +123,7 @@
%hr
.row
- .col-lg-4.profile-settings-sidebar
+ .col-lg-4
%h4.gl-mt-0= _('Profile image guideline')
.col-lg-8
diff --git a/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml b/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml
index d7bb3a85f3a..2ca037db532 100644
--- a/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml
+++ b/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml
@@ -2,7 +2,7 @@
%hr
.row
- .col-lg-4.profile-settings-sidebar
+ .col-lg-4
%h4.gl-mt-0
= _('System header and footer')
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index 022930bd6b4..2d56e9dd0dd 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -96,7 +96,6 @@
= render 'admin/application_settings/plantuml'
= render 'admin/application_settings/diagramsnet'
= render 'admin/application_settings/sourcegraph'
-= render_if_exists 'admin/application_settings/slack'
-# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/417
= render_if_exists 'admin/application_settings/dingtalk_integration'
-# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/640
@@ -109,4 +108,5 @@
= render 'admin/application_settings/floc'
= render_if_exists 'admin/application_settings/add_license'
= render 'admin/application_settings/jira_connect'
+= render 'admin/application_settings/slack'
= render_if_exists 'admin/application_settings/ai_access'
diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml
index 18ce7c1ceba..3b9fb930fd7 100644
--- a/app/views/admin/application_settings/network.html.haml
+++ b/app/views/admin/application_settings/network.html.haml
@@ -84,6 +84,9 @@
.settings-content
= render 'git_lfs_limits'
+
+= render 'gitlab_shell_operation_limits'
+
%section.settings.as-outbound.no-animate#js-outbound-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'outbound_requests_content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
diff --git a/app/views/admin/application_settings/service_usage_data.html.haml b/app/views/admin/application_settings/service_usage_data.html.haml
index 24f132b982a..634d006e736 100644
--- a/app/views/admin/application_settings/service_usage_data.html.haml
+++ b/app/views/admin/application_settings/service_usage_data.html.haml
@@ -23,7 +23,7 @@
title: _('Service Ping payload not found in the application cache')) do |c|
- c.with_body do
- - enable_service_ping_link_url = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'enable-or-disable-usage-statistics')
+ - enable_service_ping_link_url = help_page_path('administration/settings/usage_statistics', anchor: 'enable-or-disable-usage-statistics')
- enable_service_ping_link = '<a href="%{url}">'.html_safe % { url: enable_service_ping_link_url }
- generate_manually_link_url = help_page_path('development/internal_analytics/service_ping/troubleshooting', anchor: 'generate-service-ping')
- generate_manually_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: generate_manually_link_url }
diff --git a/app/views/admin/applications/_delete_form.html.haml b/app/views/admin/applications/_delete_form.html.haml
index f9fd5864176..82544d36ba0 100644
--- a/app/views/admin/applications/_delete_form.html.haml
+++ b/app/views/admin/applications/_delete_form.html.haml
@@ -1,4 +1,2 @@
-- submit_btn_css ||= 'gl-button btn btn-danger btn-danger-secondary btn-sm js-application-delete-button'
-
-%button{ class: submit_btn_css, data: { path: admin_application_path(application), name: application.name } }
+= render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, button_options: { data: { path: admin_application_path(application), name: application.name }, class: 'js-application-delete-button' }) do
= _('Destroy')
diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml
index a8d5a45041d..27622dfa0bb 100644
--- a/app/views/admin/applications/_form.html.haml
+++ b/app/views/admin/applications/_form.html.haml
@@ -32,4 +32,4 @@
.gl-mt-5
= f.submit _('Save application'), pajamas_button: true, data: { qa_selector: 'save_application_button' }
- = link_to _('Cancel'), admin_applications_path, class: "gl-button btn btn-default btn-cancel"
+ = link_button_to _('Cancel'), admin_applications_path
diff --git a/app/views/admin/cohorts/_cohorts_table.html.haml b/app/views/admin/cohorts/_cohorts_table.html.haml
index 4e2292a9f67..c39e8fb0057 100644
--- a/app/views/admin/cohorts/_cohorts_table.html.haml
+++ b/app/views/admin/cohorts/_cohorts_table.html.haml
@@ -2,7 +2,7 @@
.bs-callout.clearfix
%p
= s_("Cohorts|User cohorts are shown for the last %{months_included} months. Only users with activity are counted in the 'New users' column; inactive users are counted separately.") % { months_included: @cohorts[:months_included] }
- = link_to sprite_icon('question-o'), help_page_path('user/admin_area/user_cohorts', anchor: 'cohorts'), title: 'About this feature', target: '_blank', rel: 'noopener noreferrer'
+ = link_to sprite_icon('question-o'), help_page_path('administration/user_cohorts', anchor: 'cohorts'), title: 'About this feature', target: '_blank', rel: 'noopener noreferrer'
.table-holder.d-xl-table
%table.table
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index 4ba69126906..5f5f6c98663 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -43,7 +43,7 @@
%li
%span.light= _('Created on:')
%strong
- = @group.created_at.to_s(:medium)
+ = @group.created_at.to_fs(:medium)
%li
%span.light= _('ID:')
diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml
index a309e874317..92a664e1ca8 100644
--- a/app/views/admin/hooks/_form.html.haml
+++ b/app/views/admin/hooks/_form.html.haml
@@ -6,10 +6,10 @@
%p.form-text.text-muted= _('URL must be percent-encoded if necessary.')
.form-group
= form.label :token, _('Secret token'), class: 'label-bold'
- = form.text_field :token, class: 'form-control gl-form-input'
+ = form.password_field :token, value: hook.masked_token, autocomplete: 'new-password', class: 'form-control gl-form-input gl-max-w-48'
%p.form-text.text-muted= _('Use this token to validate received payloads.')
.form-group
- = form.label :url, _('Trigger'), class: 'label-bold'
+ = form.label :url, _('Trigger'), class: 'label-bold gl-mb-0'
.form-text.text-secondary.gl-mb-5= _('System hooks are triggered on sets of events like creating a project or adding an SSH key. You can also enable extra triggers, such as push events.')
%fieldset.form-group
= form.gitlab_ui_checkbox_component :repository_update_events, _('Repository update events'),
diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
index 14d37b77a41..29b90f69800 100644
--- a/app/views/admin/hooks/edit.html.haml
+++ b/app/views/admin/hooks/edit.html.haml
@@ -3,17 +3,17 @@
= render 'shared/web_hooks/hook_errors', hook: @hook
-.row.gl-mt-3
- .col-lg-3
- = render 'shared/web_hooks/title_and_docs', hook: @hook
+.gl-mt-5
+ = render 'shared/web_hooks/title_and_docs', hook: @hook
- .col-lg-9.gl-mb-3
- = gitlab_ui_form_for @hook, as: :hook, url: admin_hook_path do |f|
- = render partial: 'form', locals: { form: f, hook: @hook }
- .form-actions
- %span>= f.submit _('Save changes'), class: 'gl-mr-3', pajamas_button: true
+ = gitlab_ui_form_for @hook, as: :hook, url: admin_hook_path do |f|
+ = render partial: 'form', locals: { form: f, hook: @hook }
+
+ .gl-display-flex.gl-justify-content-space-between
+ %div
+ = f.submit _('Save changes'), pajamas_button: true, class: 'gl-sm-mr-3'
= render 'shared/web_hooks/test_button', hook: @hook
- = link_to _('Delete'), admin_hook_path(@hook), method: :delete, class: 'btn gl-button btn-danger float-right', aria: { label: s_('Webhooks|Delete webhook') }, data: { confirm: s_('Webhooks|Are you sure you want to delete this webhook?'), confirm_btn_variant: 'danger' }
+ = link_button_to _('Delete'), admin_hook_path(@hook), method: :delete, aria: { label: s_('Webhooks|Delete webhook') }, data: { confirm: s_('Webhooks|Are you sure you want to delete this webhook?'), confirm_btn_variant: 'danger' }, variant: :danger
%hr
diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml
index d4aeb8dc7e8..14137e788bc 100644
--- a/app/views/admin/hooks/index.html.haml
+++ b/app/views/admin/hooks/index.html.haml
@@ -1,14 +1,7 @@
- page_title @hook.pluralized_name
-.row.gl-mt-3
- .col-lg-4
- = render 'shared/web_hooks/title_and_docs', hook: @hook
-
- .col-lg-8.gl-mb-3
- = gitlab_ui_form_for @hook, as: :hook, url: admin_hooks_path do |f|
- = render partial: 'form', locals: { form: f, hook: @hook }
- = f.submit _('Add system hook'), pajamas_button: true
-
- = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class
+.settings-section
+ = render 'shared/web_hooks/title_and_docs', hook: @hook
+ = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class, partial: 'form', url: admin_hooks_path
= render 'shared/file_hooks/index'
diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml
index f4f64eadf21..19460ddb0e5 100644
--- a/app/views/admin/labels/_label.html.haml
+++ b/app/views/admin/labels/_label.html.haml
@@ -10,8 +10,8 @@
.dropdown-menu.dropdown-menu-right
%ul
%li
- = link_to edit_admin_label_path(label), class: 'btn gl-btn label-action dropdown-item btn-link' do
+ = link_to edit_admin_label_path(label), class: 'btn label-action dropdown-item btn-link' do
= _('Edit')
%li
- = link_to admin_label_path(label), class: 'btn gl-btn js-remove-label dropdown-item btn-link gl-text-red-500!', data: { confirm: _('Are you sure you want to delete this label?'), confirm_btn_variant: 'danger' }, aria: { label: _('Delete label') }, method: :delete, remote: true do
+ = link_to admin_label_path(label), class: 'btn js-remove-label dropdown-item btn-link gl-text-red-500!', data: { confirm: _('Are you sure you want to delete this label?'), confirm_btn_variant: 'danger' }, aria: { label: _('Delete label') }, method: :delete, remote: true do
= _('Delete')
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 8eb72fa281e..0637b0eae47 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -60,7 +60,7 @@
%span.light
= _('Created on:')
%strong
- = @project.created_at.to_s(:medium)
+ = @project.created_at.to_fs(:medium)
%li{ class: 'gl-px-5!' }
%span.light
@@ -158,10 +158,10 @@
= _("This repository has never been checked.")
- elsif @project.last_repository_check_failed?
- failed_message = _("This repository was last checked %{last_check_timestamp}. The check %{strong_start}failed.%{strong_end} See the 'repocheck.log' file for error messages.")
- - failed_message = failed_message % { last_check_timestamp: @project.last_repository_check_at.to_s(:medium), strong_start: "<strong class='cred'>", strong_end: "</strong>" }
+ - failed_message = failed_message % { last_check_timestamp: @project.last_repository_check_at.to_fs(:medium), strong_start: "<strong class='cred'>", strong_end: "</strong>" }
= failed_message.html_safe
- else
- = _("This repository was last checked %{last_check_timestamp}. The check passed.") % { last_check_timestamp: @project.last_repository_check_at.to_s(:medium) }
+ = _("This repository was last checked %{last_check_timestamp}. The check passed.") % { last_check_timestamp: @project.last_repository_check_at.to_fs(:medium) }
= link_to sprite_icon('question-o'), help_page_path('administration/repository_checks')
diff --git a/app/views/admin/runners/edit.html.haml b/app/views/admin/runners/edit.html.haml
index 3d245722270..6ce094bacf1 100644
--- a/app/views/admin/runners/edit.html.haml
+++ b/app/views/admin/runners/edit.html.haml
@@ -39,7 +39,8 @@
.input-group
= search_field_tag :search, params[:search], class: 'form-control gl-form-input', spellcheck: false
.input-group-append
- = submit_tag _('Search'), class: 'gl-button btn btn-default'
+ = render Pajamas::ButtonComponent.new(type: 'submit', variant: :default) do
+ = _('Search')
%td
- @projects.each do |project|
diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml
index d0ee3acf0b8..f880c2631ed 100644
--- a/app/views/admin/sessions/_new_base.html.haml
+++ b/app/views/admin/sessions/_new_base.html.haml
@@ -1,7 +1,7 @@
-= form_tag(admin_session_path, method: :post, class: 'new_user gl-show-field-errors', 'aria-live': 'assertive') do
+= gitlab_ui_form_for(:user, url: admin_session_path, html: { class: 'gl-p-5 gl-show-field-errors', aria: { live: 'assertive' } }) do |f|
.form-group
- = label_tag :user_password, _('Password'), class: 'label-bold'
- = password_field_tag 'user[password]', nil, { class: 'form-control js-password', data: { id: 'user_password', name: 'user[password]', qa_selector: 'password_field', testid: 'password-field' } }
+ = f.label :password, _('Password')
+ = f.password_field :password, class: 'form-control js-password', data: { id: 'user_password', name: 'user[password]', qa_selector: 'password_field', testid: 'password-field' }
- .submit-container
- = submit_tag _('Enter admin mode'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'enter_admin_mode_button' }
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { qa_selector: 'enter_admin_mode_button' } }) do
+ = _('Enter admin mode')
diff --git a/app/views/admin/sessions/_signin_box.html.haml b/app/views/admin/sessions/_signin_box.html.haml
index 70cad880293..114b32ca581 100644
--- a/app/views/admin/sessions/_signin_box.html.haml
+++ b/app/views/admin/sessions/_signin_box.html.haml
@@ -12,6 +12,6 @@
= render_if_exists 'devise/sessions/new_smartcard'
- if allow_admin_mode_password_authentication_for_web?
- .login-box.tab-pane.gl-p-5{ id: 'login-pane', role: 'tabpanel', class: active_when(!any_form_based_providers_enabled?) }
+ .login-box.tab-pane{ id: 'login-pane', role: 'tabpanel', class: active_when(!any_form_based_providers_enabled?) }
.login-body
= render 'admin/sessions/new_base'
diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml
index 7301b0f6e04..b3e24d5b3ac 100644
--- a/app/views/admin/sessions/new.html.haml
+++ b/app/views/admin/sessions/new.html.haml
@@ -1,8 +1,8 @@
- page_title _('Enter admin mode')
- add_page_specific_style 'page_bundles/login'
-.row.justify-content-center
- .col-md-5.new-session-forms-container
+.row.gl-mt-5.justify-content-center
+ .col-md-5
.login-page
#signin-container{ class: ('borderless' if Feature.enabled?(:restyle_login_page, @project)) }
- if any_form_based_providers_enabled?
diff --git a/app/views/admin/sessions/two_factor.html.haml b/app/views/admin/sessions/two_factor.html.haml
index bfe66e2477e..ef004004227 100644
--- a/app/views/admin/sessions/two_factor.html.haml
+++ b/app/views/admin/sessions/two_factor.html.haml
@@ -2,7 +2,7 @@
- add_page_specific_style 'page_bundles/login'
.row.justify-content-center
- .col-md-5.new-session-forms-container
+ .col-md-5
.login-page
#signin-container{ class: ('borderless' if Feature.enabled?(:restyle_login_page, @project)) }
= render 'devise/shared/tab_single', tab_title: _('Enter admin mode') if Feature.disabled?(:restyle_login_page, @project)
diff --git a/app/views/admin/topics/_topic.html.haml b/app/views/admin/topics/_topic.html.haml
index ce2b5ad793c..3e8a023ec9f 100644
--- a/app/views/admin/topics/_topic.html.haml
+++ b/app/views/admin/topics/_topic.html.haml
@@ -16,5 +16,5 @@
= number_with_delimiter(topic.total_projects_count)
.controls.gl-flex-shrink-0.gl-ml-5
- = link_to _('Edit'), edit_admin_topic_path(topic), id: "edit_#{dom_id(topic)}", class: 'btn gl-button btn-default'
- = link_to _('Remove'), admin_topic_path(topic), aria: { label: _('Remove') }, data: { confirm: _("Are you sure you want to remove %{topic_name}?") % { topic_name: title }, confirm_btn_variant: 'danger' }, method: :delete, class: 'gl-button btn btn-danger'
+ = link_button_to _('Edit'), edit_admin_topic_path(topic), id: "edit_#{dom_id(topic)}"
+ = link_button_to _('Remove'), admin_topic_path(topic), aria: { label: _('Remove') }, data: { confirm: _("Are you sure you want to remove %{topic_name}?") % { topic_name: title }, confirm_btn_variant: 'danger' }, method: :delete, variant: :danger
diff --git a/app/views/admin/users/_profile.html.haml b/app/views/admin/users/_profile.html.haml
index b4f61a1b665..bb89b5baf28 100644
--- a/app/views/admin/users/_profile.html.haml
+++ b/app/views/admin/users/_profile.html.haml
@@ -5,7 +5,7 @@
%ul.content-list
%li
%span.light= _('Member since')
- %strong= user.created_at.to_s(:medium)
+ %strong= user.created_at.to_fs(:medium)
- unless user.public_email.blank?
%li
%span.light= _('E-mail:')
diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml
index 1f3e8f4bba2..fa89c3d4b4f 100644
--- a/app/views/admin/users/projects.html.haml
+++ b/app/views/admin/users/projects.html.haml
@@ -18,8 +18,7 @@
.float-right
%span.light.vertical-align-middle= group_member.human_access
- unless group_member.owner?
- = link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member), confirm_btn_variant: 'danger', testid: 'remove-user' }, aria: { label: _('Remove') }, method: :delete, remote: true, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from group') do
- = sprite_icon('remove', size: 16, css_class: 'gl-icon')
+ = link_button_to nil, group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member), confirm_btn_variant: 'danger', testid: 'remove-user' }, aria: { label: _('Remove') }, method: :delete, remote: true, class: 'gl-ml-3', title: _('Remove user from group'), variant: :danger, size: :small, icon: 'remove'
.row
.col-md-6
@@ -50,5 +49,4 @@
%span.light.vertical-align-middle= member.human_access
- if member.respond_to? :project
- = link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member), confirm_btn_variant: 'danger' }, aria: { label: _('Remove') }, remote: true, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from project') do
- = sprite_icon('remove', size: 16, css_class: 'gl-icon')
+ = link_button_to nil, project_project_member_path(project, member), data: { confirm: remove_member_message(member), confirm_btn_variant: 'danger' }, aria: { label: _('Remove') }, remote: true, method: :delete, class: 'gl-ml-3', title: _('Remove user from project'), variant: :danger, size: :small, icon: 'remove'
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index ea6525e1b96..a4ae29bed81 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -42,8 +42,7 @@
%span.light= _('Secondary email:')
%strong
= render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? }
- = link_to remove_email_admin_user_path(@user, email), data: { confirm: _("Are you sure you want to remove %{email}?") % { email: email.email }, 'confirm-btn-variant': 'danger' }, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon float-right", title: _('Remove secondary email'), id: "remove_email_#{email.id}" do
- = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ = link_button_to nil, remove_email_admin_user_path(@user, email), data: { confirm: _("Are you sure you want to remove %{email}?") % { email: email.email }, 'confirm-btn-variant': 'danger' }, method: :delete, class: 'float-right', title: _('Remove secondary email'), id: "remove_email_#{email.id}", variant: :danger, size: :small, icon: 'close'
%li
%span.light ID:
%strong{ data: { qa_selector: 'user_id_content' } }
@@ -58,7 +57,7 @@
%strong{ class: @user.two_factor_enabled? ? 'cgreen' : 'cred' }
- if @user.two_factor_enabled?
= _('Enabled')
- = link_to _('Disable'), disable_two_factor_admin_user_path(@user), aria: { label: _('Disable') }, data: { confirm: _('Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :patch, class: 'btn gl-button btn-sm btn-danger float-right', title: _('Disable Two-factor Authentication')
+ = link_button_to _('Disable'), disable_two_factor_admin_user_path(@user), aria: { label: _('Disable') }, data: { confirm: _('Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :patch, class: 'float-right', title: _('Disable Two-factor Authentication'), variant: :danger, size: :small
- else
= _('Disabled')
@@ -86,12 +85,12 @@
%li
%span.light= _('Member since:')
%strong
- = @user.created_at.to_s(:medium)
+ = @user.created_at.to_fs(:medium)
- if @user.confirmed_at
%li
%span.light= _('Confirmed at:')
%strong
- = @user.confirmed_at.to_s(:medium)
+ = @user.confirmed_at.to_fs(:medium)
- else
%li
%span.ligh= _('Confirmed:')
@@ -106,7 +105,7 @@
%li
%span.light= _('Current sign-in at:')
%strong
- = @user.current_sign_in_at&.to_s(:medium) || _('never')
+ = @user.current_sign_in_at&.to_fs(:medium) || _('never')
%li
%span.light= _('Last sign-in IP:')
@@ -116,7 +115,7 @@
%li
%span.light= _('Last sign-in at:')
%strong
- = @user.last_sign_in_at&.to_s(:medium) || _('never')
+ = @user.last_sign_in_at&.to_fs(:medium) || _('never')
%li
%span.light= _('Sign-in count:')
diff --git a/app/views/clusters/clusters/_integrations.html.haml b/app/views/clusters/clusters/_integrations.html.haml
deleted file mode 100644
index 4d36c5094a3..00000000000
--- a/app/views/clusters/clusters/_integrations.html.haml
+++ /dev/null
@@ -1,16 +0,0 @@
-.settings.expanded.border-0.m-0
- %p
- = s_('ClusterIntegration|Integrations allow you to use applications installed in your cluster as part of your GitLab workflow.')
- = link_to _('Learn more'), help_page_path('user/clusters/integrations.md'), target: '_blank', rel: 'noopener noreferrer'
- .settings-content#integrations-settings-section
- - if can?(current_user, :admin_cluster, @cluster)
- .sub-section.form-group
- = gitlab_ui_form_for @prometheus_integration, as: :integration, namespace: :prometheus, url: @cluster.integrations_path, method: :post, html: { class: 'js-cluster-integrations-form' } do |prometheus_form|
- = prometheus_form.hidden_field :application_type, value: @prometheus_integration.application_type
- .form-group.gl-form-group
- - help_text = s_('ClusterIntegration|Allows GitLab to query a specifically configured in-cluster Prometheus for metrics.')
- - help_link = link_to(_('More information.'), help_page_path("user/clusters/integrations"), target: '_blank', rel: 'noopener noreferrer')
- = prometheus_form.gitlab_ui_checkbox_component :enabled,
- s_('ClusterIntegration|Enable Prometheus integration'),
- help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link }
- = prometheus_form.submit _('Save changes'), class: 'btn gl-button btn-confirm'
diff --git a/app/views/clusters/clusters/_integrations_tab.html.haml b/app/views/clusters/clusters/_integrations_tab.html.haml
deleted file mode 100644
index e229c1fbe1e..00000000000
--- a/app/views/clusters/clusters/_integrations_tab.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- active = params[:tab] == 'integrations'
-
-= gl_tab_link_to clusterable.cluster_path(@cluster.id, params: { tab: 'integrations' }), { item_active: active } do
- = _('Integrations')
diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml
index 57de6d980f8..1287f4e689f 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -32,7 +32,6 @@
= gl_tabs_nav do
= render 'clusters/clusters/details_tab'
= render_if_exists 'clusters/clusters/environments_tab'
- = render 'clusters/clusters/integrations_tab' if !Feature.enabled?(:remove_monitor_metrics)
= render 'clusters/clusters/advanced_settings_tab'
.tab-content.py-3
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index ebd7f20c54a..e20fccc218a 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -20,7 +20,7 @@
- else
= _("(removed)")
- .todo-body.gl-mb-2.gl-px-2.gl-display-flex.gl-align-items-flex-start.gl-lg-align-items-center
+ .todo-body.gl-mb-2.gl-px-2.gl-display-flex.gl-align-items-flex-start
.todo-avatar.gl-display-none.gl-sm-display-inline-block
= author_avatar(todo, size: 24)
.todo-note
@@ -47,6 +47,8 @@
%span.action-description<
= first_line_in_markdown(todo, :body, 125, is_todo: true, project: todo.project, group: todo.group)
+ = render_if_exists "dashboard/todos/diff_summary", local_assigns: { todo: todo }
+
.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-secondary
= todo_due_date(todo)
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index ca6b1071f03..c5f70397fad 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -53,7 +53,7 @@
- if params[:action_id].present?
= hidden_field_tag(:action_id, params[:action_id])
= dropdown_tag(todo_actions_dropdown_label(params[:action_id], _("Action")), options: { toggle_class: 'js-action-search js-filter-submit gl-xs-w-full!', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit', data: { data: todo_actions_options, default_label: _("Action") } })
- .filter-item.sort-filter.gl-mt-3.gl-sm-mt-0.gl-mb-0.gl-sm-mb-0
+ .filter-item.sort-filter.gl-my-2
.dropdown
%button.dropdown-menu-toggle.dropdown-menu-toggle-sort{ type: 'button', class: 'gl-xs-w-full!', 'data-toggle' => 'dropdown' }
%span.light
diff --git a/app/views/devise/sessions/_new_crowd.html.haml b/app/views/devise/sessions/_new_crowd.html.haml
index bb398eaf4be..4b1441662ab 100644
--- a/app/views/devise/sessions/_new_crowd.html.haml
+++ b/app/views/devise/sessions/_new_crowd.html.haml
@@ -7,7 +7,7 @@
= f.text_field :username, name: :username, autocomplete: :username, class: 'form-control gl-form-input', title: _('This field is required.'), autofocus: 'autofocus', required: true
.form-group
= f.label :password, _('Password')
- = f.text_field :vue_password_placeholder, class: 'form-control gl-form-input js-password', data: { id: "#{:crowd}_password", name: 'password' }
+ %input.form-control.gl-form-input.js-password{ data: { id: 'crowd_password', name: 'password' } }
- if render_remember_me
= f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { name: :remember_me, autocomplete: 'off' }
diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml
index f9b6f462661..471cc053e6e 100644
--- a/app/views/devise/sessions/_new_ldap.html.haml
+++ b/app/views/devise/sessions/_new_ldap.html.haml
@@ -9,7 +9,7 @@
= f.text_field :username, name: :username, autocomplete: :username, class: 'form-control gl-form-input', title: _('This field is required.'), autofocus: 'autofocus', data: { qa_selector: 'username_field' }, required: true
.form-group
= f.label :password, _('Password')
- = f.text_field :vue_password_placeholder, class: 'form-control gl-form-input js-password', data: { id: "#{provider}_password", name: 'password', qa_selector: 'password_field' }
+ %input.form-control.gl-form-input.js-password{ data: { id: "#{provider}_password", name: 'password', qa_selector: 'password_field' } }
- if render_remember_me
= f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { name: :remember_me, autocomplete: 'off' }
diff --git a/app/views/devise/shared/_footer.html.haml b/app/views/devise/shared/_footer.html.haml
index 0744faa148c..c35e43b909e 100644
--- a/app/views/devise/shared/_footer.html.haml
+++ b/app/views/devise/shared/_footer.html.haml
@@ -1,10 +1,11 @@
-%hr.footer-fixed
-.container.footer-container.gl-display-flex.gl-justify-content-space-between
- .footer-links
- - unless public_visibility_restricted?
- = link_to _("Explore"), explore_root_path
- = link_to _("Help"), help_path
- = link_to _("About GitLab"), "https://#{ApplicationHelper.promo_host}"
- = link_to _("Community forum"), ApplicationHelper.community_forum, target: '_blank', class: 'text-nowrap', rel: 'noopener noreferrer'
- = render 'devise/shared/language_switcher'
+.footer-container.gl-w-full.gl-align-self-end
+ %hr.gl-m-0
+ .container.gl-py-5.gl-display-flex.gl-justify-content-space-between
+ .gl-display-flex.gl-gap-5.gl-flex-wrap
+ - unless public_visibility_restricted?
+ = link_to _("Explore"), explore_root_path
+ = link_to _("Help"), help_path
+ = link_to _("About GitLab"), "https://#{ApplicationHelper.promo_host}"
+ = link_to _("Community forum"), ApplicationHelper.community_forum, target: '_blank', class: 'text-nowrap', rel: 'noopener noreferrer'
+ = render 'devise/shared/language_switcher'
= footer_message
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index 684ade87720..6d37257232b 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -5,14 +5,14 @@
.gl-mb-3.gl-p-4{ class: (borderless ? '' : 'gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-base') }
= yield :omniauth_providers_top if show_omniauth_providers
- = gitlab_ui_form_for(resource, as: form_resource_name, url: url, html: { class: 'new_user gl-show-field-errors js-arkose-labs-form', 'aria-live' => 'assertive' }, data: { testid: 'signup-form' }) do |f|
+ = gitlab_ui_form_for(resource, as: form_resource_name, url: url, html: { class: '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
= invisible_captcha nonce: true, autocomplete: SecureRandom.alphanumeric(12)
.name.form-row
.col.form-group
- = f.label :first_name, _('First name'), for: 'new_user_first_name', class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}"
+ = f.label :first_name, _('First name'), for: 'new_user_first_name'
= f.text_field :first_name,
class: 'form-control gl-form-input top js-block-emoji js-validate-length',
data: { max_length: max_first_name_length,
@@ -21,7 +21,7 @@
required: true,
title: _('This field is required.')
.col.form-group
- = f.label :last_name, _('Last name'), for: 'new_user_last_name', class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}"
+ = f.label :last_name, _('Last name'), for: 'new_user_last_name'
= f.text_field :last_name,
class: 'form-control gl-form-input top js-block-emoji js-validate-length',
data: { max_length: max_last_name_length,
@@ -30,7 +30,7 @@
required: true,
title: _('This field is required.')
.username.form-group
- = f.label :username, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}"
+ = f.label :username, _('Username')
= f.text_field :username,
class: 'form-control gl-form-input middle js-block-emoji js-validate-length js-validate-username',
data: signup_username_data_attributes,
@@ -41,7 +41,7 @@
%p.validation-success.gl-text-green-600.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is available.')
%p.validation-pending.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Checking username availability...')
.form-group
- = f.label :email, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}"
+ = f.label :email, _('Email')
= f.email_field :email,
class: 'form-control gl-form-input middle js-validate-email',
data: { qa_selector: 'new_user_email_field' },
@@ -52,7 +52,7 @@
-# This is used for providing entry to Jihu on email verification
= render_if_exists 'devise/shared/signup_email_additional_info'
.form-group.gl-mb-5
- = f.label :password, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}"
+ = f.label :password, _('Password')
%input.form-control.gl-form-input.js-password{ data: { id: "#{form_resource_name}_password",
title: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length },
minimum_password_length: @minimum_password_length,
@@ -62,18 +62,16 @@
%p.gl-field-hint-valid.text-secondary= s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length }
= render_if_exists 'shared/password_requirements_list'
= render_if_exists 'devise/shared/phone_verification', form: f
- %div
- - if arkose_labs_enabled?
- = render_if_exists 'devise/registrations/arkose_labs'
- - elsif show_recaptcha_sign_up?
- = recaptcha_tags nonce: content_security_policy_nonce
+ .form-group
+ - if arkose_labs_enabled?
+ = render_if_exists 'devise/registrations/arkose_labs'
+ - elsif show_recaptcha_sign_up?
+ = recaptcha_tags nonce: content_security_policy_nonce
+
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { qa_selector: 'new_user_register_button' }}) do
+ = button_text
- .submit-container.gl-mt-5
- = 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)
= render 'devise/shared/terms_of_service_notice', button_text: button_text
= yield :omniauth_providers_bottom if show_omniauth_providers
diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml
index e8c82e456ae..60c37316c62 100644
--- a/app/views/devise/shared/_signup_omniauth_provider_list.haml
+++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml
@@ -4,7 +4,7 @@
= _("Register with:")
.gl-text-center.gl-ml-auto.gl-mr-auto
- providers.each do |provider|
- = button_to omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }, id: "oauth-login-#{provider}" do
+ = render Pajamas::ButtonComponent.new(href: omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), method: :post, variant: :default, button_options: { class: "gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label}, id: "oauth-login-#{provider}" }) do
- if provider_has_icon?(provider)
= provider_image_tag(provider)
%span.gl-button-text
@@ -14,7 +14,7 @@
= _("Create an account using:")
.gl-display-flex.gl-justify-content-between.gl-flex-wrap
- providers.each do |provider|
- = button_to omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }, id: "oauth-login-#{provider}" do
+ = render Pajamas::ButtonComponent.new(href: omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), method: :post, variant: :default, button_options: { class: "gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label}, id: "oauth-login-#{provider}" }) do
- if provider_has_icon?(provider)
= provider_image_tag(provider)
%span.gl-button-text
diff --git a/app/views/devise/unlocks/new.html.haml b/app/views/devise/unlocks/new.html.haml
index b9d50e48d05..8bae27020c2 100644
--- a/app/views/devise/unlocks/new.html.haml
+++ b/app/views/devise/unlocks/new.html.haml
@@ -1,14 +1,18 @@
= render 'devise/shared/tab_single', tab_title: _('Resend unlock instructions')
.login-box
.login-body
- = gitlab_ui_form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f|
+ = gitlab_ui_form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post, class: 'gl-p-5 gl-show-field-errors' }) do |f|
.devise-errors
= render "devise/shared/error_messages", resource: resource
- .form-group.gl-mb-6
- = f.label :email
- = f.email_field :email, class: 'form-control', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', title: _('Please provide a valid email address.')
- .clearfix
- = f.submit _('Resend unlock instructions'), pajamas_button: true, class: 'gl-w-full'
+ .form-group
+ = f.label :email, _('Email')
+ = f.email_field :email, class: 'form-control gl-form-input', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', title: _('Please provide a valid email address.')
-.clearfix.prepend-top-20
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true) do
+ = _('Resend unlock instructions')
+
+- if Feature.enabled?(:restyle_login_page, @project)
= render 'devise/shared/sign_in_link'
+- else
+ .gl-mt-3
+ = render 'devise/shared/sign_in_link'
diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml
index a35ba12dd52..e34a5cebe78 100644
--- a/app/views/discussions/_notes.html.haml
+++ b/app/views/discussions/_notes.html.haml
@@ -15,16 +15,14 @@
= badge_counter
= render partial: "shared/notes/note", collection: discussion.notes, as: :note, locals: { badge_counter: badge_counter, show_image_comment_badge: show_image_comment_badge }
- .flash-container
-
- .discussion-reply-holder
- - if can_create_note?
- .discussion-with-resolve-btn
- = link_to_reply_discussion(discussion)
- - elsif !current_user
- .disabled-comment.text-center
- Please
- = link_to "register", new_session_path(:user, redirect_to_referer: 'yes')
- or
- = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes')
- to reply
+ %li.discussion-reply-holder.clearfix{ class: 'gl-border-t-0! gl-pb-5!' }
+ - if can_create_note?
+ .discussion-with-resolve-btn
+ = link_to_reply_discussion(discussion)
+ - elsif !current_user
+ .disabled-comment.text-center
+ Please
+ = link_to "register", new_session_path(:user, redirect_to_referer: 'yes')
+ or
+ = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes')
+ to reply
diff --git a/app/views/events/_event_push.atom.haml b/app/views/events/_event_push.atom.haml
index c3786d7c16d..51b60fe0152 100644
--- a/app/views/events/_event_push.atom.haml
+++ b/app/views/events/_event_push.atom.haml
@@ -6,7 +6,7 @@
= link_to "(#{truncate_sha(event.commit_id)})", event_url if event_url
%i
at
- = event.created_at.to_s(:short)
+ = event.created_at.to_fs(:short)
- unless event.rm_ref?
.blockquote= markdown(escape_once(event.commit_title), pipeline: :atom, project: event.project, author: event.author)
- if event.commits_count > 1
diff --git a/app/views/explore/projects/_project.atom.builder b/app/views/explore/projects/_project.atom.builder
new file mode 100644
index 00000000000..f0500901a73
--- /dev/null
+++ b/app/views/explore/projects/_project.atom.builder
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+xml.entry do
+ xml.title project.name
+ xml.link href: project_url(project), rel: "alternate", type: "text/html"
+ xml.id project_url(project)
+ xml.updated project.created_at
+
+ if project.description.present?
+ xml.summary(type: "xhtml") do |summary|
+ summary << project.description
+ end
+ end
+end
diff --git a/app/views/explore/projects/topic.atom.builder b/app/views/explore/projects/topic.atom.builder
new file mode 100644
index 00000000000..4712d415daa
--- /dev/null
+++ b/app/views/explore/projects/topic.atom.builder
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+xml.title @topic.name
+xml.link href: topic_explore_projects_url(@topic.name, rss_url_options), rel: "self", type: "application/atom+xml"
+xml.link href: topic_explore_projects_url(@topic.name), rel: "alternate", type: "text/html"
+xml.id topic_explore_projects_url(@topic.id)
+xml.updated @projects[0].updated_at.xmlschema if @projects[0]
+
+xml << render(@projects) if @projects.any?
diff --git a/app/views/explore/projects/topic.html.haml b/app/views/explore/projects/topic.html.haml
index b26abefcb0e..329e7cc161c 100644
--- a/app/views/explore/projects/topic.html.haml
+++ b/app/views/explore/projects/topic.html.haml
@@ -22,9 +22,10 @@
%div{ class: container_class }
.gl-py-5.gl-border-gray-100.gl-border-b-solid.gl-border-b-1
%h3.gl-m-0= _('Projects with this topic')
- .top-area.gl-pt-2.gl-pb-2
+ .top-area.gl-pt-2.gl-pb-2.gl-justify-content-space-between
.nav-controls
= render 'shared/projects/search_form'
= render 'filter'
+ = link_button_to nil, topic_explore_projects_path(@topic.name, rss_url_options), title: s_("Topics|Subscribe to the new projects feed"), class: 'd-none d-sm-inline-flex has-tooltip', icon: 'rss'
= render 'projects', projects: @projects
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index 04bf3f98a1e..e5c66c2c432 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -3,16 +3,18 @@
.row.gl-mt-3
.col-lg-12
- .gl-display-flex.gl-flex-wrap
+ .gl-display-flex.gl-flex-wrap.gl-justify-content-space-between
- if can_admin_group_member?(@group)
%h4
= _('Group members')
%p.gl-w-full.order-md-1
= group_member_header_subtext(@group)
- .gl-display-flex.gl-flex-wrap.gl-align-items-flex-start.gl-ml-auto.gl-md-w-auto.gl-w-full.gl-mt-3
+ .gl-display-flex.gl-flex-wrap.gl-align-items-center.gl-gap-3.gl-md-w-auto.gl-w-full
.js-invite-group-trigger{ data: { classes: 'gl-md-w-auto gl-w-full', display_text: _('Invite a group') } }
+ - if can_admin_service_accounts?(@group)
+ = render_if_exists 'groups/group_members/create_service_account'
.js-invite-members-trigger{ data: { variant: 'confirm',
- classes: 'gl-md-w-auto gl-w-full gl-md-ml-3 gl-md-mt-0 gl-mt-3',
+ classes: 'gl-md-w-auto gl-w-full',
trigger_source: 'group-members-page',
display_text: _('Invite members') } }
= render 'groups/invite_groups_modal', group: @group, reload_page_on_submit: true
diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml
index 89f460606cb..e84fd7a8692 100644
--- a/app/views/groups/milestones/_form.html.haml
+++ b/app/views/groups/milestones/_form.html.haml
@@ -10,12 +10,16 @@
= render "shared/milestones/form_dates", f: f
.form-group
= f.label :description, _("Description")
- = render layout: 'shared/md_preview', locals: { url: group_preview_markdown_path } do
- = render 'shared/zen', f: f, attr: :description,
- classes: 'note-textarea',
- qa_selector: 'milestone_description_field',
- supports_autocomplete: true,
- placeholder: _('Write milestone description...')
+ - @gfm_form = true
+ .js-markdown-editor{ data: { render_markdown_path: group_preview_markdown_path,
+ markdown_docs_path: help_page_path('user/markdown'),
+ qa_selector: 'milestone_description_field',
+ form_field_placeholder: _('Write milestone description...'),
+ supports_quick_actions: 'false',
+ enable_autocomplete: 'true',
+ autofocus: 'false',
+ form_field_classes: 'note-textarea js-gfm-input markdown-area' } }
+ = f.hidden_field :description
.clearfix
.error-alert
diff --git a/app/views/groups/packages/index.html.haml b/app/views/groups/packages/index.html.haml
index b6cf26c3677..6d0f24bf08c 100644
--- a/app/views/groups/packages/index.html.haml
+++ b/app/views/groups/packages/index.html.haml
@@ -10,4 +10,5 @@
npm_instance_url: package_registry_instance_url(:npm),
project_list_url: '',
settings_path: show_group_package_registry_settings(@group) ? group_settings_packages_and_registries_path(@group) : '',
+ can_delete_packages: can_delete_group_packages?(@group).to_s,
group_list_url: group_packages_path(@group) } }
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index 8c73fc95544..22ed6ea4403 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -33,7 +33,7 @@
= render 'shared/choose_avatar_button', f: f
- if @group.avatar?
%hr
- = link_to s_('Groups|Remove avatar'), group_avatar_path(@group.to_param), aria: { label: s_('Groups|Remove avatar') }, data: { confirm: s_('Groups|Avatar will be removed. Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :delete, class: 'gl-button btn btn-danger-secondary'
+ = link_button_to s_('Groups|Remove avatar'), group_avatar_path(@group.to_param), aria: { label: s_('Groups|Remove avatar') }, data: { confirm: s_('Groups|Avatar will be removed. Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :delete, variant: :danger, category: :secondary
.form-group.gl-form-group
= render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group
= f.submit s_('Groups|Save changes'), pajamas_button: true, class: 'js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' }
diff --git a/app/views/groups/settings/access_tokens/index.html.haml b/app/views/groups/settings/access_tokens/index.html.haml
index 96a492e599e..ac3be429461 100644
--- a/app/views/groups/settings/access_tokens/index.html.haml
+++ b/app/views/groups/settings/access_tokens/index.html.haml
@@ -4,40 +4,39 @@
- type_plural = _('group access tokens')
- @force_desktop_expanded_sidebar = true
-.row.gl-mt-3.js-search-settings-section
- .col-lg-4
- %h4.gl-mt-0
- = page_title
- %p
- - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/settings/group_access_tokens') }
+.settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = page_title
+ %p.gl-text-secondary
+ - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/settings/group_access_tokens') }
- if current_user.can?(:create_resource_access_tokens, @group)
= _('Generate group access tokens scoped to this group for your applications that need access to the GitLab API.')
- %p
- = html_escape(_('You can also use group access tokens with Git to authenticate over HTTP(S). %{link_start}Learn more.%{link_end}')) % { link_start: link_start, link_end: '</a>'.html_safe }
+ = html_escape(_('You can also use group access tokens with Git to authenticate over HTTP(S). %{link_start}Learn more.%{link_end}')) % { link_start: help_link_start, link_end: '</a>'.html_safe }
- else
- = html_escape(_('Group access token creation is disabled in this group. You can still use and manage existing tokens. %{link_start}Learn more.%{link_end}')) % { link_start: link_start, link_end: '</a>'.html_safe }
- %p
+ = _('Group access token creation is disabled in this group.')
- root_group = @group.root_ancestor
- if current_user.can?(:admin_group, root_group)
- group_settings_link = edit_group_path(root_group)
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_settings_link }
= html_escape(_('You can enable group access token creation in %{link_start}group settings%{link_end}.')) % { link_start: link_start, link_end: '</a>'.html_safe }
+ = html_escape(_('You can still use and manage existing tokens. %{link_start}Learn more.%{link_end}')) % { link_start: help_link_start, link_end: '</a>'.html_safe }
- .col-lg-8
- #js-new-access-token-app{ data: { access_token_type: type } }
+ #js-new-access-token-app{ data: { access_token_type: type } }
- - if current_user.can?(:create_resource_access_tokens, @group)
- = render 'shared/access_tokens/form',
- ajax: true,
- type: type,
- path: group_settings_access_tokens_path(@group),
- resource: @group,
- token: @resource_access_token,
- scopes: @scopes,
- access_levels: GroupMember.access_level_roles,
- default_access_level: Gitlab::Access::GUEST,
- prefix: :resource_access_token,
- help_path: help_page_path('user/group/settings/group_access_tokens', anchor: 'scopes-for-a-group-access-token')
+ - if current_user.can?(:create_resource_access_tokens, @group)
+ = render 'shared/access_tokens/form',
+ ajax: true,
+ type: type,
+ path: group_settings_access_tokens_path(@group),
+ resource: @group,
+ token: @resource_access_token,
+ scopes: @scopes,
+ access_levels: GroupMember.access_level_roles,
+ default_access_level: Gitlab::Access::GUEST,
+ prefix: :resource_access_token,
+ help_path: help_page_path('user/group/settings/group_access_tokens', anchor: 'scopes-for-a-group-access-token')
- #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true
+ #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true
} }
diff --git a/app/views/groups/settings/ci_cd/_form.html.haml b/app/views/groups/settings/ci_cd/_form.html.haml
index d31d22c61be..9b23a8c5e0e 100644
--- a/app/views/groups/settings/ci_cd/_form.html.haml
+++ b/app/views/groups/settings/ci_cd/_form.html.haml
@@ -7,5 +7,5 @@
= f.number_field :max_artifacts_size, class: 'form-control'
%p.form-text.text-muted
= _("The maximum file size in megabytes for individual job artifacts.")
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/continuous_integration', anchor: 'maximum-artifacts-size'), target: '_blank', rel: 'noopener noreferrer'
= f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/import/fogbugz/new.html.haml b/app/views/import/fogbugz/new.html.haml
index bd0e4b51a63..2edd9cd5592 100644
--- a/app/views/import/fogbugz/new.html.haml
+++ b/app/views/import/fogbugz/new.html.haml
@@ -24,4 +24,5 @@
.col-md-4
= password_field_tag :password, nil, class: 'form-control gl-form-input'
.form-actions
- = submit_tag _('Continue to the next step'), class: 'gl-button btn btn-confirm'
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm) do
+ = _('Continue to the next step')
diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml
index 5f65405c8bc..d368f013e6b 100644
--- a/app/views/invites/show.html.haml
+++ b/app/views/invites/show.html.haml
@@ -6,7 +6,7 @@
%p
= _("You are already a member of this %{member_source}.") % { member_source: @invite_details[:title] }
.actions
- = link_to _("Go to %{source_name}") % { source_name: @invite_details[:title] }, @invite_details[:url], class: "btn gl-button btn-confirm"
+ = link_button_to _("Go to %{source_name}") % { source_name: @invite_details[:title] }, @invite_details[:url], variant: :confirm
- else
%p
diff --git a/app/views/layouts/_google_tag_manager_head.html.haml b/app/views/layouts/_google_tag_manager_head.html.haml
index 21b9a604a35..711a3d66ff7 100644
--- a/app/views/layouts/_google_tag_manager_head.html.haml
+++ b/app/views/layouts/_google_tag_manager_head.html.haml
@@ -1,4 +1,5 @@
- return unless google_tag_manager_enabled?
+
- if Feature.enabled?(:gitlab_gtm_datalayer, type: :ops)
= javascript_tag do
:plain
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index d3a4c5c5ba8..53ecad1b474 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -3,12 +3,11 @@
- omit_og = sign_in_with_redirect?
%head{ omit_og ? { } : { prefix: "og: http://ogp.me/ns#" } }
%meta{ charset: "utf-8" }
-
- %title= page_title(site_name)
-
- = render 'layouts/loading_hints'
-
%meta{ 'http-equiv' => 'X-UA-Compatible', content: 'IE=edge' }
+ %meta{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1' }
+ %title= page_title(site_name)
+ = Gon::Base.render_data(nonce: content_security_policy_nonce)
+ = yield :project_javascripts
= render 'layouts/startup_js'
= yield :startup_js
@@ -18,14 +17,9 @@
= yield :prefetch_asset_tags
- = favicon_link_tag favicon, id: 'favicon', data: { original_href: favicon }, type: 'image/png'
-
- - if startup_css_enabled?
- = render 'layouts/startup_css', { startup_filename: local_assigns.fetch(:startup_filename, nil) }
- - else
- - diffs_colors = user_diffs_colors
- = stylesheet_link_tag "themes/#{user_application_theme_css_filename}" if user_application_theme_css_filename
- = render 'layouts/diffs_colors_css', diffs_colors if diffs_colors.present? || request.path == profile_preferences_path
+ - diffs_colors = user_diffs_colors
+ = stylesheet_link_tag "themes/#{user_application_theme_css_filename}" if user_application_theme_css_filename
+ = render 'layouts/diffs_colors_css', diffs_colors if diffs_colors.present? || request.path == profile_preferences_path
- if user_application_theme == 'gl-dark'
%meta{ name: 'color-scheme', content: 'dark light' }
@@ -43,13 +37,9 @@
= stylesheet_link_tag_defer "highlight/themes/#{user_color_scheme}"
- - if startup_css_enabled?
- = render 'layouts/startup_css_activation'
-
= stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
= render 'layouts/snowplow'
-
- = Gon::Base.render_data(nonce: content_security_policy_nonce)
+ = render 'layouts/loading_hints'
= render_if_exists 'layouts/header/translations'
- if Feature.enabled?(:enable_new_sentry_clientside_integration, current_user) && Gitlab::CurrentSettings.sentry_enabled
@@ -64,8 +54,6 @@
= webpack_controller_bundle_tags
- = yield :project_javascripts
-
- unless omit_og
-# Open Graph - http://ogp.me/
%meta{ property: 'og:type', content: "object" }
@@ -84,16 +72,13 @@
%meta{ property: 'twitter:image', content: page_image }
= page_card_meta_tags
- %meta{ name: "description", content: page_description }
-
- %link{ rel: 'manifest', href: manifest_path(format: :json) }
- %meta{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1' }
- %meta{ name: 'theme-color', content: user_theme_primary_color }
-
= csrf_meta_tags
= csp_meta_tag
= action_cable_meta_tag
+ %link{ rel: 'manifest', href: manifest_path(format: :json) }
+ = favicon_link_tag favicon, id: 'favicon', data: { original_href: favicon }, type: 'image/png'
+
-# Apple Safari/iOS home screen icons
= favicon_link_tag 'apple-touch-icon.png', rel: 'apple-touch-icon'
@@ -106,3 +91,5 @@
= render 'layouts/matomo' if extra_config.has_key?('matomo_url') && extra_config.has_key?('matomo_site_id')
-# This is needed by [GitLab JH](https://gitlab.com/gitlab-jh/gitlab/-/issues/184)
= render_if_exists "layouts/frontend_monitor"
+ %meta{ name: "description", content: page_description }
+ %meta{ name: 'theme-color', content: user_theme_primary_color }
diff --git a/app/views/layouts/_header_search.html.haml b/app/views/layouts/_header_search.html.haml
index 1d5f2583bbd..add518723e5 100644
--- a/app/views/layouts/_header_search.html.haml
+++ b/app/views/layouts/_header_search.html.haml
@@ -9,7 +9,7 @@
%input{ id: 'search', name: 'search', type: "text", placeholder: s_('GlobalSearch|Search GitLab'),
class: 'form-control gl-form-input gl-search-box-by-type-input',
autocomplete: 'off',
- data: { qa_selector: 'search_box' } }
+ data: { testid: 'search_box' } }
= hidden_field_tag :group_id, header_search_context[:group][:id] if header_search_context[:group]
= hidden_field_tag :project_id, header_search_context[:project][:id] if header_search_context[:project]
diff --git a/app/views/layouts/_img_loader.html.haml b/app/views/layouts/_img_loader.html.haml
index 979ebeb0a02..c1fe3ae0924 100644
--- a/app/views/layouts/_img_loader.html.haml
+++ b/app/views/layouts/_img_loader.html.haml
@@ -13,6 +13,6 @@
img.removeAttribute('data-src');
img.classList.remove('lazy');
img.classList.add('js-lazy-loaded');
- img.dataset.qa_selector = 'js_lazy_loaded_content';
+ img.dataset.testid = 'js_lazy_loaded_content';
});
}
diff --git a/app/views/layouts/_mailer.html.haml b/app/views/layouts/_mailer.html.haml
index 95ebe09a2e6..8b864c2685e 100644
--- a/app/views/layouts/_mailer.html.haml
+++ b/app/views/layouts/_mailer.html.haml
@@ -1,5 +1,5 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-%html{ lang: "en" }
+%html{ lang: I18n.locale }
%head
%meta{ content: "text/html; charset=UTF-8", "http-equiv" => "Content-Type" }/
%meta{ content: "width=device-width, initial-scale=1", name: "viewport" }/
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 8e52f973e9e..3bb59db32aa 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -5,9 +5,9 @@
-# Render the parent group sidebar while creating a new subgroup/project, see GroupsController#new.
- group = @parent_group || @group
- - sidebar_panel = super_sidebar_nav_panel(nav: nav, user: current_user, group: group, project: @project, current_ref: current_ref, ref_type: @ref_type, viewed_user: @user)
+ - sidebar_panel = super_sidebar_nav_panel(nav: nav, user: current_user, group: group, project: @project, current_ref: current_ref, ref_type: @ref_type, viewed_user: @user, organization: @organization)
- sidebar_data = super_sidebar_context(current_user, group: group, project: @project, panel: sidebar_panel, panel_type: nav).to_json
- %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, toggle_new_nav_endpoint: profile_preferences_url, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s } }
+ %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, toggle_new_nav_endpoint: profile_preferences_url, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s, command_palette: command_palette_data(project: @project).to_json } }
- if display_whats_new?
#whats-new-app{ data: { version_digest: whats_new_version_digest } }
diff --git a/app/views/layouts/_startup_css.haml b/app/views/layouts/_startup_css.haml
deleted file mode 100644
index 64a86cf319e..00000000000
--- a/app/views/layouts/_startup_css.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-- startup_filename_default = user_application_theme == 'gl-dark' ? 'dark' : 'general'
-- startup_filename = local_assigns.fetch(:startup_filename, nil) || startup_filename_default
-- diffs_colors = user_diffs_colors
-
-%style
- = Rails.application.assets_manifest.find_sources("themes/#{user_application_theme_css_filename}.css").first.to_s.html_safe if user_application_theme_css_filename
- = Rails.application.assets_manifest.find_sources("startup/startup-#{startup_filename}.css").first.to_s.html_safe
-
-= render 'layouts/diffs_colors_css', diffs_colors if diffs_colors.present? || request.path == profile_preferences_path
diff --git a/app/views/layouts/_startup_css_activation.haml b/app/views/layouts/_startup_css_activation.haml
deleted file mode 100644
index 7dfb9cd1530..00000000000
--- a/app/views/layouts/_startup_css_activation.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-= javascript_tag do
- :plain
- document.querySelectorAll('link[media="print"]').forEach(linkTag => {
- linkTag.setAttribute('data-startupcss', 'loading');
- const startupLinkLoadedEvent = new CustomEvent('CSSStartupLinkLoaded');
- linkTag.addEventListener('load',function(){this.media='all';this.setAttribute('data-startupcss', 'loaded');document.dispatchEvent(startupLinkLoadedEvent);},{once: true});
- })
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index 6e1d3ba678c..94f25a9f0ae 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -1,19 +1,19 @@
- add_page_specific_style 'page_bundles/login'
- custom_text = custom_sign_in_description
!!! 5
-%html.devise-layout-html{ class: system_message_class }
+%html.html-devise-layout{ lang: I18n.locale }
= render "layouts/head", { startup_filename: 'signin' }
- %body.login-page.application.navless{ class: "#{user_application_theme} #{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page' } }
+ %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{user_application_theme} #{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page' } }
= header_message
= render "layouts/init_client_detection_flags"
- if Feature.enabled?(:restyle_login_page, @project)
- .page-wrap.borderless
- .container.navless-container
+ .gl-h-full.borderless.gl-display-flex.gl-flex-wrap
+ .container
.content
= render "layouts/flash"
- if custom_text.present?
.row
- .col-md.order-12.sm-bg-gray-10
+ .col-md.order-12.sm-bg-gray
.col-sm-12
%h1.mb-3.gl-font-size-h2
= brand_title
@@ -33,11 +33,11 @@
.gl-w-half.gl-xs-w-full.gl-ml-auto.gl-mr-auto.bar
= yield
- = render 'devise/shared/footer', footer_message: footer_message
+ = render 'devise/shared/footer'
- else
- .page-wrap
- = render "layouts/header/empty"
- .container.navless-container
+ = render "layouts/header/empty"
+ .gl-h-full.gl-display-flex.gl-flex-wrap
+ .container
.content
= render "layouts/flash"
.row.mt-3
@@ -60,7 +60,7 @@
%p
= _('This is a self-managed instance of GitLab.')
- .col-md-6.order-1.new-session-forms-container{ class: recently_confirmed_com? ? 'order-sm-first' : 'order-sm-12' }
+ .col-md-6.order-1{ class: recently_confirmed_com? ? 'order-sm-first' : 'order-sm-12' }
= yield
- = render 'devise/shared/footer', footer_message: footer_message
+ = render 'devise/shared/footer'
diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml
index 89aba85984f..3e969b866a6 100644
--- a/app/views/layouts/devise_empty.html.haml
+++ b/app/views/layouts/devise_empty.html.haml
@@ -1,15 +1,15 @@
- add_page_specific_style 'page_bundles/login'
!!! 5
-%html.devise-layout-html{ lang: "en", class: system_message_class }
+%html.html-devise-layout{ lang: I18n.locale }
= render "layouts/head"
- %body.login-page.application.navless{ class: "#{user_application_theme} #{client_class_list}" }
+ %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{user_application_theme} #{client_class_list}" }
= header_message
= render "layouts/init_client_detection_flags"
= render "layouts/header/empty"
- = render "layouts/broadcast"
- .container.navless-container
- .content
- = render "layouts/flash"
- = yield
+ .gl-h-full.gl-display-flex.gl-flex-wrap
+ .container
+ .content
+ = render "layouts/flash"
+ = yield
- = render 'devise/shared/footer', footer_message: footer_message
+ = render 'devise/shared/footer'
diff --git a/app/views/layouts/errors.html.haml b/app/views/layouts/errors.html.haml
index 3ddd8c6780f..5ad20478f51 100644
--- a/app/views/layouts/errors.html.haml
+++ b/app/views/layouts/errors.html.haml
@@ -1,5 +1,5 @@
!!! 5
-%html{ lang: "en" }
+%html{ lang: I18n.locale }
%head
%meta{ :content => "width=device-width, initial-scale=1, maximum-scale=1", :name => "viewport" }
%title= yield(:title)
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index 65dbafc19da..e04ffc2e88a 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -3,7 +3,7 @@
%ul
%li.current-user
- if current_user_menu?(:profile)
- = link_to current_user, class: 'gl-line-height-20!', data: { user: current_user.username, testid: 'user-profile-link', track_action: "click_link", track_label: "user_profile", track_property: "navigation_top", qa_selector: 'user_profile_link' } do
+ = link_to current_user, class: 'gl-line-height-20!', data: { user: current_user.username, testid: 'user-profile-link', track_action: "click_link", track_label: "user_profile", track_property: "navigation_top" } do
= render 'layouts/header/current_user_dropdown_item'
- else
.gl-py-3.gl-px-4
@@ -19,7 +19,7 @@
= dispensable_render_if_exists 'layouts/header/start_trial'
- if current_user_menu?(:settings)
%li
- = link_to s_("CurrentUser|Edit profile"), profile_path, data: { qa_selector: 'edit_profile_link', track_action: "click_link", track_label: "user_edit_profile", track_property: "navigation_top" }
+ = link_to s_("CurrentUser|Edit profile"), profile_path, data: { testid: 'edit_profile_link', track_action: "click_link", track_label: "user_edit_profile", track_property: "navigation_top" }
%li
= link_to s_("CurrentUser|Preferences"), profile_preferences_path, data: { track_action: "click_link", track_label: "user_preferences", track_property: "navigation_top" }
= render_if_exists 'layouts/header/buy_pipeline_minutes', project: @project, namespace: @group
@@ -48,4 +48,4 @@
- if current_user_menu?(:sign_out)
%li.divider
%li
- = link_to _("Sign out"), destroy_user_session_path, method: :post, class: "sign-out-link", data: { qa_selector: 'sign_out_link', track_action: "click_link", track_label: "user_sign_out", track_property: "navigation_top" }
+ = link_to _("Sign out"), destroy_user_session_path, method: :post, class: "sign-out-link", data: { testid: 'sign_out_link', track_action: "click_link", track_label: "user_sign_out", track_property: "navigation_top" }
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 2c6ccb4abaf..1c22a853dd0 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -1,7 +1,7 @@
- has_impersonation_link = header_link?(:admin_impersonation)
- user_status_data = user_status_properties(current_user)
-%header.navbar.navbar-gitlab.navbar-expand-sm.js-navbar{ data: { qa_selector: 'navbar' } }
+%header.navbar.navbar-gitlab.navbar-expand-sm.js-navbar{ data: { testid: 'navbar' } }
%a.gl-sr-only.gl-accessibility{ href: "#content-body" } Skip to content
.container-fluid
.header-content.js-header-content
@@ -12,7 +12,7 @@
= brand_header_logo
.gl-display-flex.gl-align-items-center
- if Gitlab.com_and_canary?
- = gl_badge_tag({ variant: :success, size: :sm }, { href: Gitlab::Saas.canary_toggle_com_url, data: { qa_selector: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer', class: 'canary-badge' }) do
+ = gl_badge_tag({ variant: :success, size: :sm }, { href: Gitlab::Saas.canary_toggle_com_url, data: { testid: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer', class: 'canary-badge' }) do
= _('Next')
- if current_user
@@ -47,7 +47,7 @@
- if header_link?(:issues)
= nav_link(path: 'dashboard#issues', html_options: { class: "user-counter" }) do
= link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues js-prefetch-document', aria: { label: _('Issues') },
- data: { qa_selector: 'issues_shortcut_button', toggle: 'tooltip', placement: 'bottom',
+ data: { testid: 'issues_shortcut_button', toggle: 'tooltip', placement: 'bottom',
track_label: 'main_navigation',
track_action: 'click_issues_link',
track_property: 'navigation_top',
@@ -60,7 +60,7 @@
= nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter dropdown" }) do
- top_level_link = assigned_mrs_dashboard_path
= link_to top_level_link, class: 'dashboard-shortcuts-merge_requests has-tooltip', title: _('Merge requests'), aria: { label: _('Merge requests') },
- data: { qa_selector: 'merge_requests_shortcut_button',
+ data: { testid: 'merge_requests_shortcut_button',
toggle: "dropdown",
placement: 'bottom',
track_label: 'merge_requests_menu',
@@ -92,7 +92,7 @@
- if header_link?(:todos)
= nav_link(controller: 'dashboard/todos', html_options: { class: "user-counter" }) do
= link_to dashboard_todos_path, title: _('To-Do List'), aria: { label: _('To-Do List') }, class: 'shortcuts-todos js-prefetch-document',
- data: { qa_selector: 'todos_shortcut_button', toggle: 'tooltip', placement: 'bottom',
+ data: { testid: 'todos_shortcut_button', toggle: 'tooltip', placement: 'bottom',
track_label: 'main_navigation',
track_action: 'click_to_do_link',
track_property: 'navigation_top',
@@ -115,16 +115,16 @@
%li.nav-item.gl-display-none.gl-sm-display-block
= render "layouts/nav/top_nav"
- if header_link?(:user_dropdown)
- %li.nav-item.header-user.js-nav-user-dropdown.dropdown{ data: { qa_selector: 'user_menu', testid: 'user-menu' }, class: ('mr-0' if has_impersonation_link) }
+ %li.nav-item.header-user.js-nav-user-dropdown.dropdown{ data: { testid: 'user-dropdown' }, class: ('mr-0' if has_impersonation_link) }
= link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown", track_label: "profile_dropdown", track_action: "click_dropdown", track_property: "navigation_top" } do
- = render Pajamas::AvatarComponent.new(current_user, size: 24, class: 'header-user-avatar', avatar_options: { data: { qa_selector: 'user_avatar_content' } })
+ = render Pajamas::AvatarComponent.new(current_user, size: 24, class: 'header-user-avatar', avatar_options: { data: { testid: 'user_avatar_content' } })
= render_if_exists 'layouts/header/user_notification_dot', project: project, namespace: group
= sprite_icon('chevron-down', css_class: 'caret-down')
.dropdown-menu.dropdown-menu-right
= render 'layouts/header/current_user_dropdown'
- if has_impersonation_link
%li.nav-item.impersonation.ml-0
- = render Pajamas::ButtonComponent.new(href: admin_impersonation_path, icon: 'incognito', button_options: { title: _('Stop impersonation'), class: 'impersonation-btn', aria: { label: _('Stop impersonation') }, data: { method: :delete, toggle: 'tooltip', placement: 'bottom', container: 'body', qa_selector: 'stop_impersonation_link' } })
+ = render Pajamas::ButtonComponent.new(href: admin_impersonation_path, icon: 'incognito', button_options: { title: _('Stop impersonation'), class: 'impersonation-btn', aria: { label: _('Stop impersonation') }, data: { method: :delete, toggle: 'tooltip', placement: 'bottom', container: 'body', testid: 'stop_impersonation_btn' } })
- if header_link?(:sign_in)
- if allow_signup?
%li.nav-item
@@ -133,7 +133,7 @@
%li.nav-item{ class: 'gl-flex-grow-0! gl-flex-basis-half!' }
= link_to _('Sign in'), new_session_path(:user, redirect_to_referer: 'yes')
- %button.navbar-toggler.d-block.d-sm-none{ type: 'button', class: 'gl-border-none!', data: { testid: 'top-nav-responsive-toggle', qa_selector: 'mobile_navbar_button' } }
+ %button.navbar-toggler.d-block.d-sm-none{ type: 'button', class: 'gl-border-none!', data: { testid: 'mobile_navbar_button' } }
%span.sr-only= _('Toggle navigation')
%span.more-icon.gl-px-3.gl-font-sm.gl-font-weight-bold
%span.gl-pr-2= _('Menu')
diff --git a/app/views/layouts/header/_new_dropdown.html.haml b/app/views/layouts/header/_new_dropdown.html.haml
index 50a2b45aa7e..3fe2894f236 100644
--- a/app/views/layouts/header/_new_dropdown.html.haml
+++ b/app/views/layouts/header/_new_dropdown.html.haml
@@ -13,7 +13,7 @@
id: "js-onboarding-new-project-link",
title: title, ref: 'tooltip', aria: { label: title },
data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static',
- qa_selector: 'new_menu_toggle', testid: 'new-dropdown' } do
+ testid: 'new-menu-toggle' } do
= sprite_icon('plus-square')
= sprite_icon('chevron-down', css_class: 'caret-down')
.dropdown-menu.dropdown-menu-right.dropdown-extended-height
diff --git a/app/views/layouts/in_product_marketing_mailer.html.haml b/app/views/layouts/in_product_marketing_mailer.html.haml
index 65c68c95d9a..312e1811e3c 100644
--- a/app/views/layouts/in_product_marketing_mailer.html.haml
+++ b/app/views/layouts/in_product_marketing_mailer.html.haml
@@ -1,5 +1,5 @@
!!!
-%html{ lang: "en" }
+%html{ lang: I18n.locale }
%head
%meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" }
%meta{ content: "width=device-width, initial-scale=1", name: "viewport" }
diff --git a/app/views/layouts/jira_connect.html.haml b/app/views/layouts/jira_connect.html.haml
index 80bbe578510..89d5abd4266 100644
--- a/app/views/layouts/jira_connect.html.haml
+++ b/app/views/layouts/jira_connect.html.haml
@@ -1,4 +1,4 @@
-%html{ lang: "en" }
+%html{ lang: I18n.locale }
%head
%meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" }
%title
diff --git a/app/views/layouts/nav/_ask_duo_button.html.haml b/app/views/layouts/nav/_ask_duo_button.html.haml
new file mode 100644
index 00000000000..f17ccfc8afe
--- /dev/null
+++ b/app/views/layouts/nav/_ask_duo_button.html.haml
@@ -0,0 +1,13 @@
+- if Gitlab.ee? && ::Gitlab::Llm::TanukiBot.show_breadcrumbs_entry_point_for?(user: current_user)
+ - label = s_('TanukiBot|Ask GitLab Duo')
+ = render Pajamas::ButtonComponent.new(variant: :confirm,
+ category: :secondary,
+ icon: 'tanuki-ai',
+ size: 'small',
+ button_options: { class: 'js-tanuki-bot-chat-toggle gl-ml-3 gl-display-none gl-md-display-inline', data: { track_action: 'click_button', track_label: 'tanuki_bot_breadcrumbs_button' }, aria: { label: label }}) do
+ = label
+ = render Pajamas::ButtonComponent.new(variant: :confirm,
+ category: :secondary,
+ icon: 'tanuki-ai',
+ size: 'small',
+ button_options: { class: 'js-tanuki-bot-chat-toggle has-tooltip gl-ml-3 gl-md-display-none', title: label, data: { track_action: 'click_button', track_label: 'tanuki_bot_breadcrumbs_button', placement: 'left' }, aria: { label: label }})
diff --git a/app/views/layouts/nav/_top_bar.html.haml b/app/views/layouts/nav/_top_bar.html.haml
index a0e03c9c0cf..73b253e18bd 100644
--- a/app/views/layouts/nav/_top_bar.html.haml
+++ b/app/views/layouts/nav/_top_bar.html.haml
@@ -5,10 +5,11 @@
- top_bar_class = [@no_top_bar_container ? 'container-fluid' : container_class, @content_class]
- top_bar_container_class = 'gl-border-b'
-%div{ class: top_bar_class }
- .top-bar-container.gl-display-flex.gl-align-items-center{ :class => top_bar_container_class }
+%div{ class: top_bar_class, data: { testid: 'top-bar' } }
+ .top-bar-container.gl-display-flex.gl-align-items-center.gl-gap-2{ :class => top_bar_container_class }
- if show_super_sidebar?
- = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'js-super-sidebar-toggle-expand super-sidebar-toggle gl-ml-n3 gl-mr-2', title: _('Expand sidebar'), aria: { controls: 'super-sidebar', expanded: 'false', label: _('Navigation sidebar') } })
+ = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'js-super-sidebar-toggle-expand super-sidebar-toggle gl-ml-n3', title: _('Expand sidebar'), aria: { controls: 'super-sidebar', expanded: 'false', label: _('Navigation sidebar') } })
- elsif defined?(@left_sidebar)
- = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'toggle-mobile-nav gl-ml-n3 gl-mr-2', data: { qa_selector: 'toggle_mobile_nav_button' }, aria: { label: _('Open sidebar') } })
+ = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'toggle-mobile-nav gl-ml-n3', data: { testid: 'toggle_mobile_nav_button' }, aria: { label: _('Open sidebar') } })
= render "layouts/nav/breadcrumbs/breadcrumbs"
+ = render "layouts/nav/ask_duo_button"
diff --git a/app/views/layouts/nav/sidebar/_organization.html.haml b/app/views/layouts/nav/sidebar/_organization.html.haml
new file mode 100644
index 00000000000..de6c87f97d7
--- /dev/null
+++ b/app/views/layouts/nav/sidebar/_organization.html.haml
@@ -0,0 +1 @@
+= render partial: 'shared/nav/sidebar', object: Sidebars::Organizations::Panel.new(organization_sidebar_context(@organization, current_user))
diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml
index c557dc36534..1f526ec221d 100644
--- a/app/views/layouts/notify.html.haml
+++ b/app/views/layouts/notify.html.haml
@@ -1,4 +1,4 @@
-%html{ lang: "en" }
+%html{ lang: I18n.locale }
%head
%meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" }
%title
diff --git a/app/views/layouts/oauth_error.html.haml b/app/views/layouts/oauth_error.html.haml
index 8d241dfd207..d5e0e8e9c1d 100644
--- a/app/views/layouts/oauth_error.html.haml
+++ b/app/views/layouts/oauth_error.html.haml
@@ -1,5 +1,5 @@
!!! 5
-%html{ lang: "en" }
+%html{ lang: I18n.locale }
%head
%meta{ :content => "width=device-width, initial-scale=1, maximum-scale=1", :name => "viewport" }
%title= yield(:title)
diff --git a/app/views/layouts/organization.html.haml b/app/views/layouts/organization.html.haml
new file mode 100644
index 00000000000..5a357c6f805
--- /dev/null
+++ b/app/views/layouts/organization.html.haml
@@ -0,0 +1,6 @@
+- page_title @organization.name
+- header_title @organization.name, organization_path(@organization)
+- nav "organization"
+- @left_sidebar = true
+
+= render template: "layouts/application"
diff --git a/app/views/layouts/service_desk.html.haml b/app/views/layouts/service_desk.html.haml
index 7ac108e7f31..13e9785317c 100644
--- a/app/views/layouts/service_desk.html.haml
+++ b/app/views/layouts/service_desk.html.haml
@@ -1,4 +1,4 @@
-%html{ lang: "en" }
+%html{ lang: I18n.locale }
%head
%meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" }
-# haml-lint:disable NoPlainNodes
diff --git a/app/views/layouts/signup_onboarding.html.haml b/app/views/layouts/signup_onboarding.html.haml
index 8cbea686d51..a5953021671 100644
--- a/app/views/layouts/signup_onboarding.html.haml
+++ b/app/views/layouts/signup_onboarding.html.haml
@@ -1,13 +1,14 @@
+- add_page_specific_style 'page_bundles/signup'
+- add_page_specific_style 'page_bundles/login'
!!! 5
-%html.devise-layout-html.navless{ class: system_message_class }
- - add_page_specific_style 'page_bundles/signup'
- - add_page_specific_style 'page_bundles/login'
+%html.html-devise-layout{ lang: I18n.locale }
= render "layouts/head"
- %body.signup-page{ class: "#{user_application_theme} #{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } }
- = render "layouts/header/logo_with_title"
+ %body.signup-page.navless{ class: "#{system_message_class} #{user_application_theme} #{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } }
+ = header_message
= render "layouts/init_client_detection_flags"
- .page-wrap
- .container.signup-box-container.navless-container
- = render "layouts/broadcast"
- .content
- = yield
+ = render "layouts/header/logo_with_title"
+ .container
+ .content
+ = yield
+
+ = footer_message
diff --git a/app/views/layouts/simple_registration.html.haml b/app/views/layouts/simple_registration.html.haml
deleted file mode 100644
index a68941b031f..00000000000
--- a/app/views/layouts/simple_registration.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-!!! 5
-%html{ lang: "en" }
- = render "layouts/head"
- - add_page_specific_style 'page_bundles/login'
- %body.login-page.application.navless{ class: user_application_theme, data: { page: body_data_page } }
- = render "layouts/header/logo_with_title"
- = render "layouts/broadcast"
- .container.navless-container.pt-0
- .content.mw-460.mx-auto
- = render "layouts/flash"
- = yield
diff --git a/app/views/notify/_successful_pipeline.html.haml b/app/views/notify/_successful_pipeline.html.haml
index 88e0bbf6125..683ee97aca3 100644
--- a/app/views/notify/_successful_pipeline.html.haml
+++ b/app/views/notify/_successful_pipeline.html.haml
@@ -100,7 +100,8 @@
%tbody
%tr
- common_style = "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;"
- - pipeline_link = content_tag(:a, "\##{@pipeline.id}", href: pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;").html_safe
+ - pipeline_link_text = sanitize_name(@pipeline.name) || "##{@pipeline.id}"
+ - pipeline_link = content_tag(:a, pipeline_link_text, href: pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;").html_safe
%td{ style: "#{common_style} font-weight:500;vertical-align:baseline;" }
= s_('Notify|Pipeline %{pipeline_link} triggered by').html_safe % { pipeline_link: pipeline_link }
- if @pipeline.user
diff --git a/app/views/notify/_successful_pipeline.text.erb b/app/views/notify/_successful_pipeline.text.erb
index 5798a2346fa..fcdeda5a213 100644
--- a/app/views/notify/_successful_pipeline.text.erb
+++ b/app/views/notify/_successful_pipeline.text.erb
@@ -24,9 +24,12 @@ Committed by: <%= commit.committer_name %>
<% job_count = @pipeline.total_size -%>
<% stage_count = @pipeline.stages_count -%>
+
+<% pipeline_link_text = sanitize_name(@pipeline.name) || "##{@pipeline.id}" %>
+
<% if @pipeline.user -%>
-Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> )
+Pipeline <%= pipeline_link_text %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> )
<% else -%>
-Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API
+Pipeline <%= pipeline_link_text %> ( <%= pipeline_url(@pipeline) %> ) triggered by API
<% end -%>
successfully completed <%= job_count %> <%= 'job'.pluralize(job_count) %> in <%= stage_count %> <%= 'stage'.pluralize(stage_count) %>.
diff --git a/app/views/notify/approved_merge_request_email.html.haml b/app/views/notify/approved_merge_request_email.html.haml
index 0b20d4f3d3a..0b856f87175 100644
--- a/app/views/notify/approved_merge_request_email.html.haml
+++ b/app/views/notify/approved_merge_request_email.html.haml
@@ -1,5 +1,5 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-%html{ lang: "en" }
+%html{ lang: I18n.locale }
%head
%meta{ content: "text/html; charset=UTF-8", "http-equiv" => "Content-Type" }/
%meta{ content: "width=device-width, initial-scale=1", name: "viewport" }/
diff --git a/app/views/notify/import_issues_csv_email.html.haml b/app/views/notify/import_issues_csv_email.html.haml
index 0008085025b..771495b57ba 100644
--- a/app/views/notify/import_issues_csv_email.html.haml
+++ b/app/views/notify/import_issues_csv_email.html.haml
@@ -1,4 +1,5 @@
- text_style = 'font-size:16px; text-align:center; line-height:30px;'
+- error_style = 'font-size:13px; text-align:center; line-height:16px; color:#dd2b0e;'
%p{ style: text_style }
- project_link = link_to(@project.full_name, project_url(@project), style: "color:#3777b0; text-decoration:none;")
@@ -16,3 +17,18 @@
- if @results[:parse_error]
%p{ style: text_style }
= s_('Notify|Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values.')
+
+- preprocess_errors = @results[:preprocess_errors]
+- if preprocess_errors.present?
+
+ - missing_milestone_errors = preprocess_errors.dig(:milestone_errors, :missing) || []
+
+ - if missing_milestone_errors.present?
+ %p{ style: error_style }
+ = s_('Notify|Could not find the following %{column} values in %{project}%{parent_groups_clause}: %{error_lines}') % { error_lines: missing_milestone_errors[:titles].join(', '),
+ column: missing_milestone_errors[:header].downcase, project: @project.full_name,
+ parent_groups_clause: @project.group.present? ? ' or its parent groups' : ''}
+
+- if @results[:error_lines].present? || preprocess_errors.present?
+ %p{ style: text_style }
+ = s_('Notify|Please fix the errors above and try the CSV import again.')
diff --git a/app/views/notify/import_issues_csv_email.text.erb b/app/views/notify/import_issues_csv_email.text.erb
index 1117f90714d..ef99914a821 100644
--- a/app/views/notify/import_issues_csv_email.text.erb
+++ b/app/views/notify/import_issues_csv_email.text.erb
@@ -9,3 +9,20 @@ Errors found on line <%= 'number'.pluralize(@results[:error_lines].size) %>: <%=
<% if @results[:parse_error] %>
Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values.
<% end %>
+
+<% preprocess_errors = @results[:preprocess_errors] %>
+<%
+ if preprocess_errors.present?
+ missing_milestone_errors = preprocess_errors.dig(:milestone_errors, :missing) || []
+%>
+
+ <% if missing_milestone_errors.present? %>
+ <%= s_('Notify|Could not find the following %{column} values in %{project}%{parent_groups_clause}: %{error_lines}') % { error_lines: missing_milestone_errors[:titles].join(', '),
+ column: missing_milestone_errors[:header].downcase, project: @project.full_name,
+ parent_groups_clause: @project.group.present? ? ' or its parent groups' : ''} %>
+ <% end %>
+<% end %>
+
+<% if @results[:error_lines].present? || preprocess_errors.present? %>
+ <%= s_('Notify|Please fix the errors above and try the CSV import again.') %>
+<% end %>
diff --git a/app/views/notify/issue_due_email.html.haml b/app/views/notify/issue_due_email.html.haml
index 9dd501022dd..f1959ce2557 100644
--- a/app/views/notify/issue_due_email.html.haml
+++ b/app/views/notify/issue_due_email.html.haml
@@ -5,7 +5,7 @@
%p
= assignees_label(@issue)
%p
- = sprintf(s_('Notify|This issue is due on: %{issue_due_date}'), { issue_due_date: @issue.due_date.to_s(:medium) }).html_safe
+ = sprintf(s_('Notify|This issue is due on: %{issue_due_date}'), { issue_due_date: @issue.due_date.to_fs(:medium) }).html_safe
- if @issue.description
.md
diff --git a/app/views/notify/merge_when_pipeline_succeeds_email.html.haml b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml
index 9c25567696f..d376a1fdecf 100644
--- a/app/views/notify/merge_when_pipeline_succeeds_email.html.haml
+++ b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml
@@ -1,5 +1,5 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-%html{ lang: "en" }
+%html{ lang: I18n.locale }
%head
%meta{ content: "text/html; charset=UTF-8", "http-equiv" => "Content-Type" }
%meta{ content: "width=device-width, initial-scale=1", name: "viewport" }
diff --git a/app/views/notify/pipeline_failed_email.html.haml b/app/views/notify/pipeline_failed_email.html.haml
index 5d4d2c0fcd8..bffb9f4ee5a 100644
--- a/app/views/notify/pipeline_failed_email.html.haml
+++ b/app/views/notify/pipeline_failed_email.html.haml
@@ -6,7 +6,7 @@
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;padding-right:5px;line-height:1;" }
%img{ alt: "✖", height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-x-red-inverted.gif'), style: "display:block;", width: "13" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;" }
- = s_('Notify|Pipeline #%{pipeline_id} has failed!') % { pipeline_id: @pipeline.id }
+ = s_('Notify|Pipeline %{pipeline_name_or_id} has failed!') % { pipeline_name_or_id: sanitize_name(@pipeline.name) || "##{@pipeline.id}" }
%tr.spacer
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" }
&nbsp;
@@ -98,7 +98,8 @@
%tbody
%tr
- common_style = "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;"
- - pipeline_link = link_to "##{@pipeline.id}", pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;"
+ - pipeline_link_text = sanitize_name(@pipeline.name) || "##{@pipeline.id}"
+ - pipeline_link = link_to pipeline_link_text, pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;"
%td{ style: "#{common_style}" }
= s_('Notify|Pipeline %{pipeline_link} triggered by').html_safe % { pipeline_link: pipeline_link }
- if @pipeline.user
diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb
index c82b7a8dd2a..97823bf3998 100644
--- a/app/views/notify/pipeline_failed_email.text.erb
+++ b/app/views/notify/pipeline_failed_email.text.erb
@@ -1,4 +1,4 @@
-Pipeline #<%= @pipeline.id %> has failed!
+<%= "Pipeline #{sanitize_name(@pipeline.name) || "##{@pipeline.id}"} has failed!" %>
Project: <%= @project.name %> ( <%= project_url(@project) %> )
Branch: <%= @pipeline.source_ref %> ( <%= commits_url(@pipeline) %> )
@@ -22,16 +22,18 @@ Committed by: <%= commit.committer_name %>
<% end -%>
<% end -%>
-<% if @pipeline.user -%>
-Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> )
-<% else -%>
-Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API
-<% end -%>
-<% failed = @pipeline.latest_statuses.failed -%>
+<% pipeline_link_text = sanitize_name(@pipeline.name) || "##{@pipeline.id}" %>
+
+<% if @pipeline.user %>
+Pipeline <%= pipeline_link_text %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> )
+<% else %>
+Pipeline <%= pipeline_link_text %> ( <%= pipeline_url(@pipeline) %> ) triggered by API
+<% end %>
+<% failed = @pipeline.latest_statuses.failed %>
had <%= failed.size %> failed <%= 'job'.pluralize(failed.size) %>.
<% failed.each do |build| -%>
<%= render "notify/links/#{build.to_partial_path}", pipeline: @pipeline, build: build %>
Stage: <%= build.stage_name %>
Name: <%= build.name %>
-<% end -%>
+<% end -%> \ No newline at end of file
diff --git a/app/views/notify/pipeline_fixed_email.html.haml b/app/views/notify/pipeline_fixed_email.html.haml
index 33b83b104b1..cf5ebb0649a 100644
--- a/app/views/notify/pipeline_fixed_email.html.haml
+++ b/app/views/notify/pipeline_fixed_email.html.haml
@@ -1 +1 @@
-= render 'notify/successful_pipeline', title: s_('Notify|Pipeline has been fixed and #%{pipeline_id} has passed!') % {pipeline_id: @pipeline.id}
+= render 'notify/successful_pipeline', title: s_('Notify|Pipeline has been fixed and %{pipeline_name_or_id} has passed!') % { pipeline_name_or_id: sanitize_name(@pipeline.name) || "##{@pipeline.id}" }
diff --git a/app/views/notify/pipeline_fixed_email.text.erb b/app/views/notify/pipeline_fixed_email.text.erb
index 32334260a5e..3b8be0cf7a0 100644
--- a/app/views/notify/pipeline_fixed_email.text.erb
+++ b/app/views/notify/pipeline_fixed_email.text.erb
@@ -1 +1 @@
-<%= render 'notify/successful_pipeline', title: "Pipeline has been fixed and ##{@pipeline.id} has passed!" -%>
+<%= render 'notify/successful_pipeline', title: "Pipeline has been fixed and #{sanitize_name(@pipeline.name) || "##{@pipeline.id}"} has passed!" -%>
diff --git a/app/views/notify/pipeline_success_email.html.haml b/app/views/notify/pipeline_success_email.html.haml
index 47832907663..3139741b5c1 100644
--- a/app/views/notify/pipeline_success_email.html.haml
+++ b/app/views/notify/pipeline_success_email.html.haml
@@ -1 +1 @@
-= render 'notify/successful_pipeline', title: "Pipeline ##{@pipeline.id} has passed!"
+= render 'notify/successful_pipeline', title: s_('Notify|Pipeline %{pipeline_name_or_id} has passed!') % { pipeline_name_or_id: sanitize_name(@pipeline.name) || "##{@pipeline.id}" }
diff --git a/app/views/notify/pipeline_success_email.text.erb b/app/views/notify/pipeline_success_email.text.erb
index 83cdb72d252..58999614d05 100644
--- a/app/views/notify/pipeline_success_email.text.erb
+++ b/app/views/notify/pipeline_success_email.text.erb
@@ -1 +1 @@
-<%= render 'notify/successful_pipeline', title: "Pipeline ##{@pipeline.id} has passed!" -%>
+<%= render 'notify/successful_pipeline', title: "Pipeline #{sanitize_name(@pipeline.name) || "##{@pipeline.id}"} has passed!" -%>
diff --git a/app/views/notify/prometheus_alert_fired_email.html.haml b/app/views/notify/prometheus_alert_fired_email.html.haml
index cdc97d583df..25dc8e59073 100644
--- a/app/views/notify/prometheus_alert_fired_email.html.haml
+++ b/app/views/notify/prometheus_alert_fired_email.html.haml
@@ -25,7 +25,3 @@
- if @alert.show_incident_issues_link?
%p
= link_to(_('View incident issues.'), @alert.incident_issues_link)
-
-- if @alert.show_performance_dashboard_link?
- %p
- = link_to(_('View performance dashboard.'), @alert.performance_dashboard_link)
diff --git a/app/views/notify/prometheus_alert_fired_email.text.erb b/app/views/notify/prometheus_alert_fired_email.text.erb
index b23cd8b6ccc..a9c1d98a396 100644
--- a/app/views/notify/prometheus_alert_fired_email.text.erb
+++ b/app/views/notify/prometheus_alert_fired_email.text.erb
@@ -18,7 +18,3 @@
<% if @alert.show_incident_issues_link? %>
<%= _('View incident issues.') %> <%= @alert.incident_issues_link %>
<% end %>
-
-<% if @alert.show_performance_dashboard_link? %>
-<%= _('View the performance dashboard at') %> <%= @alert.performance_dashboard_link %>
-<% end %>
diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml
index d493f9d5d98..199865ba644 100644
--- a/app/views/notify/repository_push_email.html.haml
+++ b/app/views/notify/repository_push_email.html.haml
@@ -19,7 +19,7 @@
%li
%strong= link_to(commit.short_id, project_commit_url(@message.project, commit))
%div
- = html_escape(s_('Notify|%{committed_by_start} by %{author_name} %{committed_by_end} %{committed_at_start} at %{committed_date} %{committed_at_end}')) % {committed_by_start: '<span>'.html_safe, author_name: commit.author_name, committed_by_end: '</span>'.html_safe, committed_at_start: '<i>'.html_safe, committed_date: commit.committed_date.to_s(:iso8601), committed_at_end: '</i>'.html_safe}
+ = html_escape(s_('Notify|%{committed_by_start} by %{author_name} %{committed_by_end} %{committed_at_start} at %{committed_date} %{committed_at_end}')) % {committed_by_start: '<span>'.html_safe, author_name: commit.author_name, committed_by_end: '</span>'.html_safe, committed_at_start: '<i>'.html_safe, committed_date: commit.committed_date.to_fs(:iso8601), committed_at_end: '</i>'.html_safe}
%pre.commit-message
= commit.safe_message
diff --git a/app/views/notify/repository_push_email.text.haml b/app/views/notify/repository_push_email.text.haml
index 2ba0a2cf4ab..38a439864b7 100644
--- a/app/views/notify/repository_push_email.text.haml
+++ b/app/views/notify/repository_push_email.text.haml
@@ -8,7 +8,7 @@
\
= @message.reverse_compare? ? "Deleted commits:" : "Commits:"
- @message.commits.each do |commit|
- #{commit.short_id} by #{commit.author_name} at #{commit.committed_date.to_s(:iso8601)}
+ #{commit.short_id} by #{commit.author_name} at #{commit.committed_date.to_fs(:iso8601)}
#{commit.safe_message}
\- - - - -
\
diff --git a/app/views/notify/unapproved_merge_request_email.html.haml b/app/views/notify/unapproved_merge_request_email.html.haml
index 94e2d0377aa..b0573251fd3 100644
--- a/app/views/notify/unapproved_merge_request_email.html.haml
+++ b/app/views/notify/unapproved_merge_request_email.html.haml
@@ -1,5 +1,5 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-%html{ lang: "en" }
+%html{ lang: I18n.locale }
%head
%meta{ content: "text/html; charset=UTF-8", "http-equiv" => "Content-Type" }/
%meta{ content: "width=device-width, initial-scale=1", name: "viewport" }/
diff --git a/app/views/organizations/organizations/directory.html.haml b/app/views/organizations/organizations/directory.html.haml
deleted file mode 100644
index 1d2fb66112b..00000000000
--- a/app/views/organizations/organizations/directory.html.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-- breadcrumb_title @organization.name
-- page_title @organization.name
diff --git a/app/views/organizations/organizations/groups_and_projects.html.haml b/app/views/organizations/organizations/groups_and_projects.html.haml
new file mode 100644
index 00000000000..8890f4b1ce5
--- /dev/null
+++ b/app/views/organizations/organizations/groups_and_projects.html.haml
@@ -0,0 +1,3 @@
+- page_title _('Groups and projects')
+
+#js-organizations-groups-and-projects
diff --git a/app/views/organizations/organizations/show.html.haml b/app/views/organizations/organizations/show.html.haml
new file mode 100644
index 00000000000..8ba2a3d96ac
--- /dev/null
+++ b/app/views/organizations/organizations/show.html.haml
@@ -0,0 +1,2 @@
+- page_title s_('Organization|Organization overview')
+- @skip_current_level_breadcrumb = true
diff --git a/app/views/profiles/accounts/_providers.html.haml b/app/views/profiles/accounts/_providers.html.haml
index 6c6fa32f736..6f0c091dfdb 100644
--- a/app/views/profiles/accounts/_providers.html.haml
+++ b/app/views/profiles/accounts/_providers.html.haml
@@ -1,6 +1,6 @@
-- button_class = 'btn btn-default gl-button gl-mb-3 gl-mr-3'
+- button_class = 'btn btn-default gl-button'
-%label.label-bold
+%label.label-bold.gl-mb-0
= s_('Profiles|Connected Accounts')
%p= s_('Profiles|Select a service to sign in with.')
- providers.each do |provider|
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index fec5d2d5ff5..799dfaae8c5 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -14,90 +14,88 @@
- c.with_body do
= html_escape(_('You have set up 2FA for your account! If you lose access to your 2FA device, you can use your recovery codes to access your account. Alternatively, if you upload an SSH key, you can %{anchorOpen}use that key to generate additional recovery codes%{anchorClose}.')) % { anchorOpen: '<a href="%{href}">'.html_safe % { href: help_page_path('user/profile/account/two_factor_authentication', anchor: 'generate-new-recovery-codes-using-ssh') }, anchorClose: '</a>'.html_safe }
-.row.gl-mt-3.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = s_('Profiles|Two-factor authentication')
+.settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = s_('Profiles|Two-factor authentication')
+ %p.gl-text-secondary
+ = s_("Profiles|Increase your account's security by enabling two-factor authentication (2FA).")
+ %div
%p
- = s_("Profiles|Increase your account's security by enabling two-factor authentication (2FA).")
- .col-lg-8
- %p
- #{_('Status')}: #{current_user.two_factor_enabled? ? _('Enabled') : _('Disabled')}
+ %span.gl-font-weight-bold
+ #{_('Status')}:
+ #{current_user.two_factor_enabled? ? _('Enabled') : _('Disabled')}
- if current_user.two_factor_enabled?
= render Pajamas::ButtonComponent.new(variant: :confirm, href: profile_two_factor_auth_path) do
= _('Manage two-factor authentication')
- else
- .gl-mb-3
- = render Pajamas::ButtonComponent.new(variant: :confirm, href: profile_two_factor_auth_path, button_options: { data: { qa_selector: 'enable_2fa_button' }}) do
- = _('Enable two-factor authentication')
- .col-lg-12
- %hr
+ = render Pajamas::ButtonComponent.new(variant: :confirm, href: profile_two_factor_auth_path, button_options: { data: { qa_selector: 'enable_2fa_button' }}) do
+ = _('Enable two-factor authentication')
- if display_providers_on_profile?
- .row.gl-mt-3.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = s_('Profiles|Service sign-in')
- %p
- = s_('Profiles|Connect a service for sign-in.')
- .col-lg-8
- = render 'providers', providers: button_based_providers, group_saml_identities: local_assigns[:group_saml_identities]
- .col-lg-12
- %hr
+ .settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = s_('Profiles|Service sign-in')
+ %p.gl-text-secondary
+ = s_('Profiles|Connect a service for sign-in.')
+ = render 'providers', providers: button_based_providers, group_saml_identities: local_assigns[:group_saml_identities]
+
- if current_user.can_change_username?
- .row.gl-mt-3.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0.warning-title
- = s_('Profiles|Change username')
- %p
- = s_('Profiles|Changing your username can have unintended side effects.')
- = succeed '.' do
- = link_to _('Learn more'), help_page_path('user/profile/index', anchor: 'change-your-username'), target: '_blank', rel: 'noopener noreferrer'
- .col-lg-8
- - data = { initial_username: current_user.username, root_url: root_url, action_url: update_username_profile_path(format: :json) }
- #update-username{ data: data }
- .col-lg-12
- %hr
+ .settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0.warning-title
+ = s_('Profiles|Change username')
+ %p.gl-text-secondary
+ = s_('Profiles|Changing your username can have unintended side effects.')
+ = succeed '.' do
+ = link_to _('Learn more'), help_page_path('user/profile/index', anchor: 'change-your-username'), target: '_blank', rel: 'noopener noreferrer'
+ - data = { initial_username: current_user.username, root_url: root_url, action_url: update_username_profile_path(format: :json) }
+ #update-username{ data: data }
- if prevent_delete_account?
- .row.gl-mt-3.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0.danger-title
- = s_('Profiles|Delete account')
- .col-lg-8
- %p
- = s_('Profiles|Account deletion is not allowed by your administrator.')
+ .settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0.danger-title
+ = s_('Profiles|Delete account')
+ %p.gl-text-secondary
+ = s_('Profiles|Account deletion is not allowed by your administrator.')
- else
- .row.gl-mt-3.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0.danger-title
- = s_('Profiles|Delete account')
- .col-lg-8
- - if current_user.can_be_removed? && can?(current_user, :destroy_user, current_user)
- %p
- = s_('Profiles|Deleting an account has the following effects:')
- = render 'users/deletion_guidance', user: current_user
-
- -# Delete button here
- = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { id: 'delete-account-button', disabled: true, data: { qa_selector: 'delete_account_button' }}) do
+ .settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-mt-0.danger-title
= s_('Profiles|Delete account')
+ - if current_user.can_be_removed? && can?(current_user, :destroy_user, current_user)
+ %p.gl-text-secondary
+ = s_('Profiles|Deleting an account has the following effects:')
+ = render 'users/deletion_guidance', user: current_user
- #delete-account-modal{ data: { action_url: user_registration_path,
- confirm_with_password: ('true' if current_user.confirm_deletion_with_password?),
- username: current_user.username } }
+ -# Delete button here
+ = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { id: 'delete-account-button', disabled: true, data: { qa_selector: 'delete_account_button' }}) do
+ = s_('Profiles|Delete account')
+
+ #delete-account-modal{ data: { action_url: user_registration_path,
+ confirm_with_password: ('true' if current_user.confirm_deletion_with_password?),
+ username: current_user.username } }
+ - else
+ - if current_user.solo_owned_groups.present?
+ %p
+ = s_('Profiles|Your account is currently an owner in these groups:')
+ %ul
+ - current_user.solo_owned_groups.each do |group|
+ %li= group.name
+ %p
+ = s_('Profiles|You must transfer ownership or delete these groups before you can delete your account.')
+ - elsif !current_user.can_remove_self?
+ %p
+ = s_('Profiles|GitLab is unable to verify your identity automatically. For security purposes, you must set a password by %{openingTag}resetting your password%{closingTag} to delete your account.').html_safe % { openingTag: "<a href='#{reset_profile_password_path}' rel=\"nofollow\" data-method=\"put\">".html_safe, closingTag: '</a>'.html_safe}
+ %p
+ = s_('Profiles|If after setting a password, the option to delete your account is still not available, please %{link_start}submit a request%{link_end} to begin the account deletion process.').html_safe % { link_start: '<a href="https://support.gitlab.io/account-deletion/" rel="nofollow noreferrer noopener" target="_blank">'.html_safe, link_end: '</a>'.html_safe}
- else
- - if current_user.solo_owned_groups.present?
- %p
- = s_('Profiles|Your account is currently an owner in these groups:')
- %strong= current_user.solo_owned_groups.map(&:name).join(', ')
- %p
- = s_('Profiles|You must transfer ownership or delete these groups before you can delete your account.')
- - elsif !current_user.can_remove_self?
- %p
- = s_('Profiles|GitLab is unable to verify your identity automatically. For security purposes, you must set a password by %{openingTag}resetting your password%{closingTag} to delete your account.').html_safe % { openingTag: "<a href='#{reset_profile_password_path}' rel=\"nofollow\" data-method=\"put\">".html_safe, closingTag: '</a>'.html_safe}
- %p
- = s_('Profiles|If after setting a password, the option to delete your account is still not available, please %{link_start}submit a request%{link_end} to begin the account deletion process.').html_safe % { link_start: '<a href="https://support.gitlab.io/account-deletion/" rel="nofollow noreferrer noopener" target="_blank">'.html_safe, link_end: '</a>'.html_safe}
- - else
- %p
- = s_("Profiles|You don't have access to delete this user.")
-.gl-mb-3
+ %p
+ = s_("Profiles|You don't have access to delete this user.")
diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/profiles/active_sessions/_active_session.html.haml
index 9ec8d694dac..e91c28e6e84 100644
--- a/app/views/profiles/active_sessions/_active_session.html.haml
+++ b/app/views/profiles/active_sessions/_active_session.html.haml
@@ -27,9 +27,5 @@
- unless is_current_session
.float-right
- = link_to(revoke_session_path(active_session),
- { data: { confirm: _('Are you sure? The device will be signed out of GitLab and all remember me tokens revoked.') },
- method: :delete,
- class: "gl-button btn btn-danger gl-ml-3" }) do
- %span.sr-only= _('Revoke')
+ = link_button_to revoke_session_path(active_session), data: { confirm: _('Are you sure? The device will be signed out of GitLab and all remember me tokens revoked.'), confirm_btn_variant: :danger }, method: :delete, class: 'gl-ml-3', variant: :danger, 'aria-label': _('Revoke') do
= _('Revoke')
diff --git a/app/views/profiles/active_sessions/index.html.haml b/app/views/profiles/active_sessions/index.html.haml
index 1952655937e..baca9559e08 100644
--- a/app/views/profiles/active_sessions/index.html.haml
+++ b/app/views/profiles/active_sessions/index.html.haml
@@ -1,16 +1,15 @@
- page_title _('Active Sessions')
- @force_desktop_expanded_sidebar = true
-.row.gl-mt-3.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = page_title
- %p
- = _('This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize.')
- .col-lg-8
- .gl-mb-3
+.settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = page_title
+ %p.gl-text-secondary
+ = _('This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize.')
- = render Pajamas::CardComponent.new(card_options: { class: 'gl-border-0' }, body_options: { class: 'gl-p-0' }) do |c|
- - c.with_body do
- %ul.list-group.list-group-flush
- = render partial: 'profiles/active_sessions/active_session', collection: @sessions
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-border-0' }, body_options: { class: 'gl-p-0' }) do |c|
+ - c.with_body do
+ %ul.list-group.list-group-flush
+ = render partial: 'profiles/active_sessions/active_session', collection: @sessions
diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml
index 44cfbc1f74f..d47f1ea7c25 100644
--- a/app/views/profiles/audit_log.html.haml
+++ b/app/views/profiles/audit_log.html.haml
@@ -1,11 +1,12 @@
- page_title _('Authentication log')
- @force_desktop_expanded_sidebar = true
-.row.gl-mt-3.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = page_title
- %p
- = _('This is a security log of authentication events involving your account.')
- .col-lg-8
- = render 'event_table', events: @events
+.settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = page_title
+ %p.gl-text-secondary
+ = _('This is a security log of authentication events involving your account.')
+
+ = render 'event_table', events: @events
diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml
index afc3894c23b..0ac8ede3c62 100644
--- a/app/views/profiles/chat_names/_chat_name.html.haml
+++ b/app/views/profiles/chat_names/_chat_name.html.haml
@@ -10,4 +10,4 @@
= _('Never')
%td
- = link_to _('Remove'), profile_chat_name_path(chat_name), method: :delete, class: 'gl-button btn btn-danger float-right', aria: { label: _('Remove') }, data: { confirm: _('Are you sure you want to remove this nickname?'), confirm_btn_variant: 'danger' }
+ = link_button_to _('Remove'), profile_chat_name_path(chat_name), method: :delete, class: 'float-right', aria: { label: _('Remove') }, data: { confirm: _('Are you sure you want to remove this nickname?'), confirm_btn_variant: 'danger' }, variant: :danger
diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml
index 264ee040d7d..7a63fc30d9c 100644
--- a/app/views/profiles/chat_names/index.html.haml
+++ b/app/views/profiles/chat_names/index.html.haml
@@ -2,29 +2,27 @@
- @hide_search_settings = true
- @force_desktop_expanded_sidebar = true
-.row.gl-mt-5.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = page_title
- %p
- = _('You can see your chat accounts.')
+.settings-section.js-search-settings-section.gl-mt-3
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = page_title
- .col-lg-8
- %h5.gl-mt-0
- = sprintf(_('Active chat names (%{count})'), { count: @chat_names.size })
+ %h5.gl-font-lg.gl-mt-0
+ = sprintf(_('Active chat names (%{count})'), { count: @chat_names.size })
- - if @chat_names.present?
- .table-responsive
- %table.table
- %thead
- %tr
- %th= _('Team domain')
- %th= _('Nickname')
- %th= _('Last used')
- %th
- %tbody
- = render @chat_names
+ - if @chat_names.present?
+ .table-responsive
+ %table.table
+ %thead
+ %tr
+ %th= _('Team domain')
+ %th= _('Nickname')
+ %th= _('Last used')
+ %th
+ %tbody
+ = render @chat_names
- - else
- .settings-message.text-center
- = _("You don't have any active chat names.")
+ - else
+ .gl-text-secondary.settings-message
+ = _("You don't have any active chat names.")
diff --git a/app/views/profiles/comment_templates/index.html.haml b/app/views/profiles/comment_templates/index.html.haml
index dd5b43aa802..0692f5d8ebb 100644
--- a/app/views/profiles/comment_templates/index.html.haml
+++ b/app/views/profiles/comment_templates/index.html.haml
@@ -1,10 +1,11 @@
-- page_title _('Comment Templates')
+- page_title _('Comment templates')
-#js-comment-templates-root.row.gl-mt-5{ data: { base_path: profile_comment_templates_path } }
- .col-lg-4
- %h4.gl-mt-0
- = page_title
- %p
- = _('Comment templates can be used when creating comments inside issues, merge requests, and epics.')
- .col-lg-8
- = gl_loading_icon(size: 'lg')
+#js-comment-templates-root.settings-section.gl-mt-3{ data: { base_path: profile_comment_templates_path } }
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = page_title
+ %p.gl-text-secondary
+ = _('Comment templates can be used when creating comments inside issues, merge requests, and epics.')
+
+ = gl_loading_icon(size: 'lg')
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index c16f3c3b12b..743c26260e4 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -1,24 +1,26 @@
- page_title _('Emails')
- @force_desktop_expanded_sidebar = true
-.row.gl-mt-3.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = page_title
- %p
- = _('Control emails linked to your account')
- .col-lg-8
- %h4.gl-mt-0
- = _('Add email address')
+.settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = _('Add email address')
+ %p.gl-text-secondary
+ = _('Control emails linked to your account')
+ %div
= gitlab_ui_form_for 'email', url: profile_emails_path do |f|
.form-group
= f.label :email, _('Email'), class: 'label-bold'
= f.text_field :email, class: 'form-control gl-form-input', data: { qa_selector: 'email_address_field' }
.gl-mt-3
= f.submit _('Add email address'), data: { qa_selector: 'add_email_address_button' }, pajamas_button: true
- %hr
- %h4.gl-mt-0
- = _('Linked emails (%{email_count})') % { email_count: @emails.load.size }
+
+.settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = _('Linked emails (%{email_count})') % { email_count: @emails.load.size }
.account-well.gl-mb-3
%ul
%li
@@ -59,8 +61,6 @@
.gl-display-flex.gl-justify-content-end.gl-align-items-flex-end.gl-flex-grow-1.gl-flex-wrap-reverse.gl-gap-3
- unless email.confirmed?
- confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}"
- = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'gl-button btn btn-sm btn-default'
+ = link_button_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, size: :small
- = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'gl-button btn btn-sm btn-danger' do
- %span.sr-only= _('Remove')
- = sprite_icon('remove')
+ = link_button_to nil, profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, variant: :danger, size: :small, icon: 'remove', 'aria-label': _('Remove')
diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml
index d52b16814c0..d8b8dda29dc 100644
--- a/app/views/profiles/gpg_keys/_key.html.haml
+++ b/app/views/profiles/gpg_keys/_key.html.haml
@@ -19,9 +19,6 @@
.float-right
%span.key-created-at
= html_escape(s_('Profiles|Created %{time_ago}')) % { time_ago: time_ago_with_tooltip(key.created_at) }
- = link_to profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: "gl-button btn btn-icon btn-danger gl-ml-3" do
- %span.sr-only= _('Remove')
- = sprite_icon('remove')
- = link_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: "gl-button btn btn-danger gl-ml-3" do
- %span.sr-only= _('Revoke')
+ = link_button_to nil, profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: 'gl-ml-3', variant: :danger, icon: 'remove', 'aria-label': _('Remove')
+ = link_button_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: 'gl-ml-3', variant: :danger, 'aria-label': _('Revoke') do
= _('Revoke')
diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml
index b21a4da16b9..2dfd6c7860f 100644
--- a/app/views/profiles/gpg_keys/index.html.haml
+++ b/app/views/profiles/gpg_keys/index.html.haml
@@ -2,21 +2,25 @@
- add_page_specific_style 'page_bundles/profile'
- @force_desktop_expanded_sidebar = true
-.row.gl-mt-3.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = page_title
- %p
- = _('GPG keys allow you to verify signed commits.')
- .col-lg-8
- %h5.gl-mt-0
- = _('Add a GPG key')
- %p.profile-settings-content
- - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/repository/gpg_signed_commits/index.md') }
- = _('Add a GPG key for secure access to GitLab. %{help_link_start}Learn more.%{help_link_end}').html_safe % {help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
- = render 'form'
- %hr
- %h5
- = _('Your GPG keys (%{count})') % { count: @gpg_keys.count }
- .gl-mb-3
- = render 'key_table'
+.settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = page_title
+ %p.gl-text-secondary
+ = _('GPG keys allow you to verify signed commits.')
+
+ %h5.gl-font-lg.gl-mt-0
+ = _('Add a GPG key')
+ %p
+ - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/repository/gpg_signed_commits/index.md') }
+ = _('Add a GPG key for secure access to GitLab. %{help_link_start}Learn more%{help_link_end}.').html_safe % {help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
+ = render 'form'
+
+.settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = _('Your GPG keys (%{count})') % { count: @gpg_keys.count }
+ .gl-mb-3
+ = render 'key_table'
diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml
index 4f3d97fb90c..f1d5a127728 100644
--- a/app/views/profiles/keys/_key_details.html.haml
+++ b/app/views/profiles/keys/_key_details.html.haml
@@ -14,13 +14,13 @@
%strong= ssh_key_usage_types.invert[@key.usage_type]
%li
%span.light= _('Created on:')
- %strong= @key.created_at.to_s(:medium)
+ %strong= @key.created_at.to_fs(:medium)
%li
%span.light= _('Expires:')
- %strong= @key.expires_at.try(:to_s, :medium) || _('Never')
+ %strong= @key.expires_at&.to_fs(:medium) || _('Never')
%li
%span.light= _('Last used on:')
- %strong= @key.last_used_at.try(:to_s, :medium) || _('Never')
+ %strong= @key.last_used_at&.to_fs(:medium) || _('Never')
.col-md-8
= form_errors(@key, type: 'key') unless @key.valid?
diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml
index e7c0cf813b5..c2e65dcc8ef 100644
--- a/app/views/profiles/keys/index.html.haml
+++ b/app/views/profiles/keys/index.html.haml
@@ -2,27 +2,27 @@
- add_page_specific_style 'page_bundles/profile'
- @force_desktop_expanded_sidebar = true
-.row.gl-mt-3.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = page_title
- %p
- = _('SSH keys allow you to establish a secure connection between your computer and GitLab.')
- %br
- %h4.gl-mt-0
- = _('SSH Fingerprints')
- %p
- - config_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_instance_configuration_url }
- = html_escape(s_('SSH fingerprints verify that the client is connecting to the correct host. Check the %{config_link_start}current instance configuration%{config_link_end}.')) % { config_link_start: config_link_start, config_link_end: '</a>'.html_safe }
- .col-lg-8
- %h5.gl-mt-0
- = _('Add an SSH key')
- %p.profile-settings-content
- - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/ssh.md') }
- = _('Add an SSH key for secure access to GitLab. %{help_link_start}Learn more.%{help_link_end}').html_safe % {help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
- = render 'form'
- %hr
- %h5
- = _('Your SSH keys (%{count})') % { count: @keys.count }
- .gl-mb-3
- = render 'key_table'
+.settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = page_title
+ %p.gl-text-secondary
+ = _('SSH keys allow you to establish a secure connection between your computer and GitLab.')
+ - config_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_instance_configuration_url }
+ = html_escape(s_('SSH fingerprints verify that the client is connecting to the correct host. Check the %{config_link_start}current instance configuration%{config_link_end}.')) % { config_link_start: config_link_start, config_link_end: '</a>'.html_safe }
+
+ %h5.gl-font-lg.gl-mt-0
+ = _('Add an SSH key')
+ %p
+ - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/ssh.md') }
+ = _('Add an SSH key for secure access to GitLab. %{help_link_start}Learn more%{help_link_end}.').html_safe % {help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
+ = render 'form'
+
+.settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = _('Your SSH keys (%{count})') % { count: @keys.count }
+ .gl-mb-3
+ = render 'key_table'
diff --git a/app/views/profiles/notifications/_email_settings.html.haml b/app/views/profiles/notifications/_email_settings.html.haml
index cd7a7ced1d4..60f366f8878 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
- .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)
+.js-notification-email-listbox-input.gl-mb-3{ data: { label: _('Global 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
= form.gitlab_ui_checkbox_component :email_opted_in, _('Receive product marketing emails')
diff --git a/app/views/profiles/notifications/_group_settings.html.haml b/app/views/profiles/notifications/_group_settings.html.haml
index 898762ca78a..1878634e56c 100644
--- a/app/views/profiles/notifications/_group_settings.html.haml
+++ b/app/views/profiles/notifications/_group_settings.html.haml
@@ -1,17 +1,15 @@
- emails_disabled = group.emails_disabled?
-.gl-responsive-table-row.notification-list-item
- .table-section.section-40
- %span.notification.gl-mr-2
+.notification-list-item.gl-md-display-flex.gl-justify-content-space-between.gl-align-items-center.gl-px-3.gl-py-4
+ .gl-mb-2.gl-md-mb-0
+ %span.gl-mr-2
= notification_icon(notification_icon_level(setting, emails_disabled))
- %span.str-truncated
+ %span
= link_to group.name, group_path(group)
- .table-section.section-30.text-right
+ .gl-display-flex.gl-gap-3.gl-flex-wrap
- if setting
- .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(setting).to_json, notification_level: setting.level, group_id: group.id, container_class: 'gl-mr-3', show_label: "true" } }
-
- .table-section.section-30
+ .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(setting).to_json, notification_level: setting.level, group_id: group.id, show_label: "true" } }
= form_for setting, url: profile_group_notifications_path(group), method: :put, html: { class: 'update-notifications gl-display-flex' } do |f|
- .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 } }
+ .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, placement: 'right' } }
diff --git a/app/views/profiles/notifications/_project_settings.html.haml b/app/views/profiles/notifications/_project_settings.html.haml
index e6953d1b32e..955449f0ba1 100644
--- a/app/views/profiles/notifications/_project_settings.html.haml
+++ b/app/views/profiles/notifications/_project_settings.html.haml
@@ -1,12 +1,13 @@
- emails_disabled = project.emails_disabled?
-%li.notification-list-item
- %span.notification.gl-mr-2
- = notification_icon(notification_icon_level(setting, emails_disabled))
+.notification-list-item.gl-md-display-flex.gl-justify-content-space-between.gl-align-items-center.gl-px-3.gl-py-4
+ .gl-mb-2.gl-md-mb-0
+ %span.gl-mr-2
+ = notification_icon(notification_icon_level(setting, emails_disabled))
- %span.str-truncated
- = link_to_project(project)
+ %span
+ = link_to_project(project)
- .float-right
+ .gl-display-flex.gl-gap-3.gl-flex-wrap
- if setting
.js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(setting).to_json, notification_level: setting.level, project_id: project.id, container_class: 'gl-mr-3', show_label: "true" } }
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index 06d37787d2e..2c7ef2b7e0e 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -2,55 +2,59 @@
- page_title _('Notifications')
- @force_desktop_expanded_sidebar = true
-%div
- - if @user.errors.any?
- = render Pajamas::AlertComponent.new(variant: :danger) do |c|
- - c.with_body do
- %ul
- - @user.errors.full_messages.each do |msg|
- %li= msg
-
- = hidden_field_tag :notification_type, 'global'
- .row.gl-mt-3.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
+- if @user.errors.any?
+ = render Pajamas::AlertComponent.new(variant: :danger) do |c|
+ - c.with_body do
+ %ul
+ - @user.errors.full_messages.each do |msg|
+ %li= msg
+
+= hidden_field_tag :notification_type, 'global'
+.settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
= page_title
- %p
- = _('You can specify notification level per group or per project.')
- %p
- = _('By default, all projects and groups will use the global notifications setting.')
- .col-lg-8
- %h5.gl-mt-0
- = _('Global notification settings')
-
- = gitlab_ui_form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications gl-mt-3' } do |f|
- = render_if_exists 'profiles/notifications/email_settings', form: f
-
- = label_tag :global_notification_level, _('Global notification level'), class: "label-bold"
- %br
- .clearfix
- .form-group.float-left.global-notification-setting
- - if @global_notification_setting
- .js-vue-notification-dropdown{ data: { dropdown_items: notification_dropdown_items(@global_notification_setting).to_json, notification_level: @global_notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), show_label: 'true' } }
-
- .clearfix
-
- = gitlab_ui_form_for @user, url: profile_notifications_path, method: :put do |f|
- .form-group
- = f.gitlab_ui_checkbox_component :notified_of_own_activity, _('Receive notifications about your own activity')
-
- %hr
- %h5
- = _('Groups (%{count})') % { count: @user_groups.total_count }
- %div
- - @group_notifications.each do |setting|
- = render 'group_settings', setting: setting, group: setting.source
- = paginate @user_groups, theme: 'gitlab'
- %h5
- = _('Projects (%{count})') % { count: @project_notifications.size }
- %p.account-well
- = _('To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there.')
- .gl-mb-3
- %ul.bordered-list
+ %p.gl-text-secondary
+ = _('You can specify notification level per group or per project.')
+
+ .gl-mt-0
+ = gitlab_ui_form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications gl-mt-3' } do |f|
+ = render_if_exists 'profiles/notifications/email_settings', form: f
+
+ = label_tag :global_notification_level, _('Global notification level'), class: "label-bold gl-mb-0"
+ .gl-text-secondary.gl-mb-3
+ = _('By default, all projects and groups use the global notifications setting.')
+
+ .form-group.global-notification-setting.gl-mb-3
+ - if @global_notification_setting
+ .js-vue-notification-dropdown{ data: { dropdown_items: notification_dropdown_items(@global_notification_setting).to_json, notification_level: @global_notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), show_label: 'true' } }
+
+ = gitlab_ui_form_for @user, url: profile_notifications_path, method: :put do |f|
+ .form-group
+ = f.gitlab_ui_checkbox_component :notified_of_own_activity, _('Receive notifications about your own activity')
+
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header'}, body_options: { class: 'gl-new-card-body' }) do |c|
+ - c.with_header do
+ %h3.gl-new-card-title
+ = _('Groups (%{count})') % { count: @user_groups.total_count }
+ - c.with_body do
+ - if @user_groups.total_count > 0
+ - @group_notifications.each do |setting|
+ = render 'group_settings', setting: setting, group: setting.source
+ = paginate @user_groups, theme: 'gitlab'
+ - else
+ .gl-new-card-empty.gl-px-3.gl-py-4= _("You do not belong to any groups yet.")
+
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header gl-display-block'}, body_options: { class: 'gl-new-card-body' }) do |c|
+ - c.with_header do
+ %h3.gl-new-card-title
+ = _('Projects (%{count})') % { count: @project_notifications.size }
+ .gl-new-card-description
+ = _('To specify the notification level per project of a group you belong to, visit the project page and change the notification level there.')
+ - c.with_body do
+ - if @project_notifications.size > 0
- @project_notifications.each do |setting|
= render 'project_settings', setting: setting, project: setting.source
+ - else
+ .gl-new-card-empty.gl-px-3.gl-py-4= _("You do not belong to any projects yet.")
diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml
index 4fdf80c1eb1..4848a9dc595 100644
--- a/app/views/profiles/passwords/edit.html.haml
+++ b/app/views/profiles/passwords/edit.html.haml
@@ -2,36 +2,34 @@
- page_title _('Password')
- @force_desktop_expanded_sidebar = true
-.row.gl-mt-3.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = page_title
- %p
- = _('After a successful password update, you will be redirected to the login page where you can log in with your new password.')
- .col-lg-8
- %h5.gl-mt-0
- - if @user.password_automatically_set
- = _('Change your password')
- - else
- = _('Change your password or recover your current one')
- = gitlab_ui_form_for @user, url: profile_password_path, method: :put, html: {class: "update-password"} do |f|
- = form_errors(@user)
+.settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = page_title
+ %p.gl-text-secondary
+ - if @user.password_automatically_set
+ = _('Change your password.')
+ - else
+ = _('Change your password or recover your current one.')
+ = gitlab_ui_form_for @user, url: profile_password_path, method: :put, html: {class: "update-password"} do |f|
+ = form_errors(@user)
- - unless @user.password_automatically_set?
- .form-group
- = f.label :password, _('Current password'), class: 'label-bold'
- = f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' }
- %p.form-text.text-muted
- = _('You must provide your current password in order to change it.')
- .form-group
- = f.label :new_password, _('New password'), class: 'label-bold'
- = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation', data: { qa_selector: 'new_password_field' }
- = render_if_exists 'shared/password_requirements_list'
+ - unless @user.password_automatically_set?
.form-group
- = f.label :password_confirmation, _('Password confirmation'), class: 'label-bold'
- = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' }
- .gl-mt-3.gl-mb-3
- = f.submit _('Save password'), class: "gl-mr-3", data: { qa_selector: 'save_password_button' }, pajamas_button: true
- - unless @user.password_automatically_set?
- = render Pajamas::ButtonComponent.new(href: reset_profile_password_path, variant: :link, method: :put) do
- = _('I forgot my password')
+ = f.label :password, _('Current password'), class: 'label-bold'
+ = f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input gl-max-w-80', data: { qa_selector: 'current_password_field' }
+ %p.form-text.text-muted
+ = _('You must provide your current password in order to change it.')
+ .form-group
+ = f.label :new_password, _('New password'), class: 'label-bold'
+ = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation gl-max-w-80', data: { qa_selector: 'new_password_field' }
+ = render_if_exists 'shared/password_requirements_list'
+ .form-group
+ = f.label :password_confirmation, _('Password confirmation'), class: 'label-bold'
+ = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input gl-max-w-80', data: { qa_selector: 'confirm_password_field' }
+ .gl-mt-3.gl-mb-3
+ = f.submit _('Save password'), class: "gl-mr-3", data: { qa_selector: 'save_password_button' }, pajamas_button: true
+ - unless @user.password_automatically_set?
+ = render Pajamas::ButtonComponent.new(href: reset_profile_password_path, variant: :link, method: :put) do
+ = _('I forgot my password')
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index 57c0badd033..5020f6cbb22 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -4,27 +4,26 @@
- type_plural = _('personal access tokens')
- @force_desktop_expanded_sidebar = true
-.row.gl-mt-3.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = page_title
- %p
- = s_('AccessTokens|You can generate a personal access token for each application you use that needs access to the GitLab API.')
- %p
- = s_('AccessTokens|You can also use personal access tokens to authenticate against Git over HTTP.')
- = s_('AccessTokens|They are the only accepted password when you have Two-Factor Authentication (2FA) enabled.')
+.settings-section.settings-section-no-bottom.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = page_title
+ %p.gl-text-secondary
+ = s_('AccessTokens|You can generate a personal access token for each application you use that needs access to the GitLab API.')
+ = s_('AccessTokens|You can also use personal access tokens to authenticate against Git over HTTP.')
+ = s_('AccessTokens|They are the only accepted password when you have Two-Factor Authentication (2FA) enabled.')
- .col-lg-8
- #js-new-access-token-app{ data: { access_token_type: type } }
+ #js-new-access-token-app{ data: { access_token_type: type } }
- = render 'shared/access_tokens/form',
- ajax: true,
- type: type,
- path: profile_personal_access_tokens_path,
- token: @personal_access_token,
- scopes: @scopes,
- help_path: help_page_path('user/profile/personal_access_tokens.md', anchor: 'personal-access-token-scopes')
+ = render 'shared/access_tokens/form',
+ ajax: true,
+ type: type,
+ path: profile_personal_access_tokens_path,
+ token: @personal_access_token,
+ scopes: @scopes,
+ help_path: help_page_path('user/profile/personal_access_tokens.md', anchor: 'personal-access-token-scopes')
- #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json } }
+ #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json } }
#js-tokens-app{ data: { tokens_data: tokens_app_data } }
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index a085840ee84..e5e7c1dc3f4 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -11,165 +11,152 @@
= stylesheet_link_tag "themes/#{theme.css_filename}" if theme.css_filename
= gitlab_ui_form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { id: "profile-preferences-form" } do |f|
- .row.gl-mt-3.js-preferences-form.js-search-settings-section
- .col-lg-4.application-theme#navigation-theme
- %h4.gl-mt-0
- = s_('Preferences|Color theme')
+ .settings-section.js-preferences-form.js-search-settings-section.application-theme#navigation-theme
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = s_('Preferences|Color theme')
+ %p.gl-text-secondary
+ = s_('Preferences|Customize the color of GitLab.')
+ - if show_super_sidebar?
%p
- = s_('Preferences|Customize the color of GitLab.')
- - if show_super_sidebar?
- %p
- = s_('Preferences|Note: You have the new navigation enabled, so only Dark Mode theme significantly changes GitLab\'s appearance.')
- .col-lg-8.application-theme
- .row
- - Gitlab::Themes.each do |theme|
- %label.col-6.col-sm-4.col-md-3.gl-mb-5.gl-text-center
- .preview{ class: theme.css_class }
- = f.gitlab_ui_radio_component :theme_id, theme.id,
- theme.name,
- radio_options: { checked: user_theme_id == theme.id }
+ = s_('Preferences|Note: You have the new navigation enabled, so only Dark Mode theme significantly changes GitLab\'s appearance.')
+ .application-theme.row
+ - Gitlab::Themes.each do |theme|
+ %label.col-6.col-sm-4.col-md-3.col-xl-2.gl-mb-5
+ .preview{ class: theme.css_class }
+ = f.gitlab_ui_radio_component :theme_id, theme.id,
+ theme.name,
+ radio_options: { checked: user_theme_id == theme.id }
- .col-sm-12
- %hr
-
- .row.js-preferences-form.js-search-settings-section
- .col-lg-4.profile-settings-sidebar#syntax-highlighting-theme
- %h4.gl-mt-0
- = s_('Preferences|Syntax highlighting theme')
- %p
- = s_('Preferences|Customize the appearance of the syntax.')
- = succeed '.' do
- = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank', rel: 'noopener noreferrer'
- .col-lg-8.syntax-theme
+ .settings-section.js-preferences-form.js-search-settings-section#syntax-highlighting-theme
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = s_('Preferences|Syntax highlighting theme')
+ %p.gl-text-secondary
+ = s_('Preferences|Customize the appearance of the syntax.')
+ = succeed '.' do
+ = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank', rel: 'noopener noreferrer'
+ .syntax-theme.row
- Gitlab::ColorSchemes.each do |scheme|
- = label_tag do
+ %label.col-6.col-sm-4.col-md-3.col-lg-auto.gl-mb-5
.preview= image_tag "#{scheme.css_class}-scheme-preview.png"
= f.gitlab_ui_radio_component :color_scheme_id, scheme.id,
- scheme.name,
- radio_options: { checked: user_color_schema_id == scheme.id }
+ scheme.name,
+ radio_options: { checked: user_color_schema_id == scheme.id }
- .col-sm-12
- %hr
+ .settings-section.js-preferences-form.js-search-settings-section#diffs-colors
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = s_('Preferences|Diff colors')
+ %p.gl-text-secondary
+ = s_('Preferences|Customize the colors of removed and added lines in diffs.')
+ .form-group
+ #js-profile-preferences-diffs-colors-app{ data: user_diffs_colors }
- .row.js-preferences-form.js-search-settings-section
- .col-lg-4.profile-settings-sidebar#diffs-colors
- %h4.gl-mt-0
- = s_('Preferences|Diff colors')
- %p
- = s_('Preferences|Customize the colors of removed and added lines in diffs.')
- .col-lg-8
- .form-group
- #js-profile-preferences-diffs-colors-app{ data: user_diffs_colors }
+ .settings-section.js-preferences-form.js-search-settings-section#behavior
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = s_('Preferences|Behavior')
+ %p.gl-text-secondary
+ = s_('Preferences|Customize the behavior of the system layout and default views.')
+ = succeed '.' do
+ = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank', rel: 'noopener noreferrer'
+ .form-group
+ = f.label :layout, class: 'label-bold' do
+ = s_('Preferences|Layout width')
+ = f.select :layout, layout_choices, {}, class: 'gl-form-select custom-select'
+ .form-text.text-muted
+ = s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' }
+ .js-listbox-input{ data: { label: s_('Preferences|Homepage'), description: s_('Preferences|Choose what content you want to see by default on your homepage.'), name: 'user[dashboard]', items: dashboard_choices.to_json, value: current_user.dashboard, block: true.to_s, fluid_width: true.to_s } }
- .col-sm-12
- %hr
+ = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific
- .row.js-preferences-form.js-search-settings-section
- .col-lg-4.profile-settings-sidebar#behavior
- %h4.gl-mt-0
- = s_('Preferences|Behavior')
- %p
- = s_('Preferences|Customize the behavior of the system layout and default views.')
- = succeed '.' do
- = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank', rel: 'noopener noreferrer'
- .col-lg-8
- .form-group
- = f.label :layout, class: 'label-bold' do
- = s_('Preferences|Layout width')
- = f.select :layout, layout_choices, {}, class: 'gl-form-select custom-select'
- .form-text.text-muted
- = s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' }
- .js-listbox-input{ data: { label: s_('Preferences|Homepage'), description: s_('Preferences|Choose what content you want to see by default on your homepage.'), name: 'user[dashboard]', items: dashboard_choices.to_json, value: current_user.dashboard } }
+ .form-group
+ = f.label :project_view, class: 'label-bold' do
+ = s_('Preferences|Project overview content')
+ = f.select :project_view, project_view_choices, {}, class: 'gl-form-select custom-select'
+ .form-text.text-muted
+ = s_('Preferences|Choose what content you want to see on a project’s overview page.')
+ .form-group
+ = f.gitlab_ui_checkbox_component :project_shortcut_buttons, s_('Preferences|Show shortcut buttons above files on project overview')
+ .form-group
+ = f.gitlab_ui_checkbox_component :render_whitespace_in_code, s_('Preferences|Render whitespace characters in the Web IDE')
+ .form-group
+ = f.gitlab_ui_checkbox_component :show_whitespace_in_diffs, s_('Preferences|Show whitespace changes in diffs')
+ .form-group
+ = f.gitlab_ui_checkbox_component :view_diffs_file_by_file,
+ s_("Preferences|Show one file at a time on merge request's Changes tab"),
+ help_text: s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.")
+ .form-group
+ - supported_characters = %w(" ' ` &#40; [ { < * _).map { |char| "<code>#{char}</code>" }.join(', ')
+ = f.gitlab_ui_checkbox_component :markdown_surround_selection,
+ s_('Preferences|Surround text selection when typing quotes or brackets'),
+ help_text: sprintf(s_("Preferences|When you type in a description or comment box, selected text is surrounded by the corresponding character after typing one of the following characters: %{supported_characters}."), { supported_characters: supported_characters }).html_safe
+ .form-group
+ = f.gitlab_ui_checkbox_component :markdown_automatic_lists,
+ s_('Preferences|Automatically add new list items'),
+ help_text: html_escape(s_('Preferences|When you type in a description or comment box, pressing %{kbdOpen}Enter%{kbdClose} in a list adds a new item below.')) % { kbdOpen: '<kbd>'.html_safe, kbdClose: '</kbd>'.html_safe }
- = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific
+ .form-group
+ = f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold'
+ = f.number_field :tab_width,
+ class: 'form-control gl-form-input',
+ min: Gitlab::TabWidth::MIN,
+ max: Gitlab::TabWidth::MAX,
+ required: true
+ .form-text.text-muted
+ = s_('Preferences|Must be a number between %{min} and %{max}') % { min: Gitlab::TabWidth::MIN, max: Gitlab::TabWidth::MAX }
- .form-group
- = f.label :project_view, class: 'label-bold' do
- = s_('Preferences|Project overview content')
- = f.select :project_view, project_view_choices, {}, class: 'gl-form-select custom-select'
- .form-text.text-muted
- = s_('Preferences|Choose what content you want to see on a project’s overview page.')
- .form-group
- = f.gitlab_ui_checkbox_component :project_shortcut_buttons, s_('Preferences|Show shortcut buttons above files on project overview')
- .form-group
- = f.gitlab_ui_checkbox_component :render_whitespace_in_code, s_('Preferences|Render whitespace characters in the Web IDE')
- .form-group
- = f.gitlab_ui_checkbox_component :show_whitespace_in_diffs, s_('Preferences|Show whitespace changes in diffs')
- .form-group
- = f.gitlab_ui_checkbox_component :view_diffs_file_by_file,
- s_("Preferences|Show one file at a time on merge request's Changes tab"),
- help_text: s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.")
- .form-group
- - supported_characters = %w(" ' ` &#40; [ { < * _).map { |char| "<code>#{char}</code>" }.join(', ')
- = f.gitlab_ui_checkbox_component :markdown_surround_selection,
- s_('Preferences|Surround text selection when typing quotes or brackets'),
- help_text: sprintf(s_("Preferences|When you type in a description or comment box, selected text is surrounded by the corresponding character after typing one of the following characters: %{supported_characters}."), { supported_characters: supported_characters }).html_safe
- .form-group
- = f.gitlab_ui_checkbox_component :markdown_automatic_lists,
- s_('Preferences|Automatically add new list items'),
- help_text: html_escape(s_('Preferences|When you type in a description or comment box, pressing %{kbdOpen}Enter%{kbdClose} in a list adds a new item below.')) % { kbdOpen: '<kbd>'.html_safe, kbdClose: '</kbd>'.html_safe }
+ .settings-section.js-preferences-form.js-search-settings-section#localization
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = _('Localization')
+ %p.gl-text-secondary
+ = _('Customize language and region related settings.')
+ = succeed '.' do
+ = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'localization'), target: '_blank', rel: 'noopener noreferrer'
+ .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, block: true.to_s, fluid_width: true.to_s } }
+ %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')
+ = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'gl-form-select custom-select'
- .form-group
- = f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold'
- = f.number_field :tab_width,
- class: 'form-control gl-form-input',
- min: Gitlab::TabWidth::MIN,
- max: Gitlab::TabWidth::MAX,
- required: true
- .form-text.text-muted
- = s_('Preferences|Must be a number between %{min} and %{max}') % { min: Gitlab::TabWidth::MIN, max: Gitlab::TabWidth::MAX }
-
- .col-sm-12
- %hr
- .row.js-preferences-form.js-search-settings-section
- .col-lg-4.profile-settings-sidebar#localization
- %h4.gl-mt-0
- = _('Localization')
- %p
- = _('Customize language and region related settings.')
- = succeed '.' do
- = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'localization'), target: '_blank', rel: 'noopener noreferrer'
- .col-lg-8
- .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')
- = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'gl-form-select custom-select'
- .col-sm-12
- %hr
- .row.js-preferences-form.js-search-settings-section
- .col-lg-4.profile-settings-sidebar#time-preferences
- %h4.gl-mt-0
- = s_('Preferences|Time preferences')
- %p
- = s_('Preferences|Configure how dates and times display for you.')
+ .settings-section.js-preferences-form.js-search-settings-section#time-preferences
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = s_('Preferences|Time preferences')
+ %p.gl-text-secondary
+ = s_('Preferences|Configure how dates and times display for you.')
+ = succeed '.' do
+ = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'time-preferences'), target: '_blank', rel: 'noopener noreferrer'
+ .form-group
+ = f.gitlab_ui_checkbox_component :time_display_relative,
+ s_('Preferences|Use relative times'),
+ help_text: s_('Preferences|For example: 30 minutes ago.')
+ - if Feature.enabled?(:disable_follow_users, @user)
+ .settings-section.js-preferences-form.js-search-settings-section#enabled_following
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = s_('Preferences|Enable follow users feature')
+ %p.gl-text-secondary
+ = s_('Preferences|Turns on or off the ability to follow or be followed by other users.')
= succeed '.' do
- = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'time-preferences'), target: '_blank', rel: 'noopener noreferrer'
- .col-lg-8
+ = link_to _('Learn more'), help_page_path('user/profile/index', anchor: 'follow-users'), target: '_blank', rel: 'noopener noreferrer'
.form-group
- = f.gitlab_ui_checkbox_component :time_display_relative,
- s_('Preferences|Use relative times'),
- help_text: s_('Preferences|For example: 30 minutes ago.')
- - if Feature.enabled?(:disable_follow_users, @user)
- .row.js-preferences-form.js-search-settings-section
- .col-sm-12
- %hr
- .col-lg-4.profile-settings-sidebar#enabled_following
- %h4.gl-mt-0
- = s_('Preferences|Enable follow users feature')
- %p
- = s_('Preferences|Turns on or off the ability to follow or be followed by other users.')
- = succeed '.' do
- = link_to _('Learn more'), help_page_path('user/profile/index', anchor: 'follow-users'), target: '_blank', rel: 'noopener noreferrer'
- .col-lg-8
- .form-group
- = f.gitlab_ui_checkbox_component :enabled_following,
- s_('Preferences|Enable follow users')
+ = f.gitlab_ui_checkbox_component :enabled_following,
+ s_('Preferences|Enable follow users')
= render_if_exists 'profiles/preferences/code_suggestions_settings', form: f
= render_if_exists 'profiles/preferences/zoekt_settings', form: f
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 1a932ed7b35..ebdea5786f5 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -7,50 +7,50 @@
- if Feature.enabled?(:edit_user_profile_vue, current_user)
.js-user-profile
- else
- = gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f|
- .row.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = s_("Profiles|Public avatar")
- %p
- - if @user.avatar?
- - if gravatar_enabled?
- = s_("Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}").html_safe % { gravatar_link: gravatar_link }
- - else
- = s_("Profiles|You can change your avatar here")
- - else
- - if gravatar_enabled?
- = s_("Profiles|You can upload your avatar here or change it at %{gravatar_link}").html_safe % { gravatar_link: gravatar_link }
- - else
- = s_("Profiles|You can upload your avatar here")
- - if current_appearance&.profile_image_guidelines?
- .md
- = brand_profile_image_guidelines
- .col-lg-8
- .avatar-image
- = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
- = render Pajamas::AvatarComponent.new(@user, size: 96, alt: "", class: 'gl-float-left gl-mr-5')
- %h5.gl-mt-0= s_("Profiles|Upload new avatar")
- .gl-display-flex.gl-align-items-center.gl-my-3
- = render Pajamas::ButtonComponent.new(button_options: { class: 'js-choose-user-avatar-button' }) do
- = s_("Profiles|Choose file...")
- %span.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.")
- = f.file_field :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*'
- .gl-text-gray-500= s_("Profiles|The maximum file size allowed is 200KB.")
+ = gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f|
+ .settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = s_("Profiles|Public avatar")
+ %p.gl-text-secondary
- if @user.avatar?
- = render Pajamas::ButtonComponent.new(variant: :danger,
- category: :secondary,
- href: profile_avatar_path,
- button_options: { class: 'gl-mt-3', data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") } },
- method: :delete) do
- = s_("Profiles|Remove avatar")
- .col-lg-12
- %hr
- .row.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0= s_("Profiles|Current status")
- %p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.")
- .col-lg-8
+ - if gravatar_enabled?
+ = s_("Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}").html_safe % { gravatar_link: gravatar_link }
+ - else
+ = s_("Profiles|You can change your avatar here")
+ - else
+ - if gravatar_enabled?
+ = s_("Profiles|You can upload your avatar here or change it at %{gravatar_link}").html_safe % { gravatar_link: gravatar_link }
+ - else
+ = s_("Profiles|You can upload your avatar here")
+ - if current_appearance&.profile_image_guidelines?
+ .md
+ = brand_profile_image_guidelines
+ .avatar-image
+ = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
+ = render Pajamas::AvatarComponent.new(@user, size: 96, alt: "", class: 'gl-float-left gl-mr-5')
+ %h5.gl-mt-0= s_("Profiles|Upload new avatar")
+ .gl-display-flex.gl-align-items-center.gl-my-3
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-choose-user-avatar-button' }) do
+ = s_("Profiles|Choose file...")
+ %span.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.")
+ = f.file_field :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*'
+ .gl-text-gray-500= s_("Profiles|The maximum file size allowed is 200KB.")
+ - if @user.avatar?
+ = render Pajamas::ButtonComponent.new(variant: :danger,
+ category: :secondary,
+ href: profile_avatar_path,
+ button_options: { class: 'gl-mt-3', data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") } },
+ method: :delete) do
+ = s_("Profiles|Remove avatar")
+
+ .settings-section.js-search-settings-section.gl-border-t.gl-pt-6
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0= s_("Profiles|Current status")
+ %p.gl-text-secondary= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.")
+ .gl-max-w-80
#js-user-profile-set-status-form
= f.fields_for :status, @user.status do |status_form|
= status_form.hidden_field :emoji, data: { js_name: 'emoji' }
@@ -59,121 +59,117 @@
= status_form.hidden_field :clear_status_after,
value: user_clear_status_at(@user),
data: { js_name: 'clearStatusAfter' }
- .col-lg-12
- %hr
- .row.user-time-preferences.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0= s_("Profiles|Time settings")
- %p= s_("Profiles|Set your local time zone.")
- .col-lg-8
- = f.label :user_timezone, _("Time zone")
- .js-timezone-dropdown{ data: { timezone_data: timezone_data.to_json, initial_value: @user.timezone, name: 'user[timezone]' } }
- .col-lg-12
- %hr
- .row.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = s_("Profiles|Main settings")
- %p
- = s_("Profiles|This information will appear on your profile.")
- - if current_user.ldap_user?
- = s_("Profiles|Some options are unavailable for LDAP accounts")
- .col-lg-8
- .row
- .form-group.gl-form-group.col-md-9.rspec-full-name
- = render 'profiles/name', form: f, user: @user
- .form-group.gl-form-group.col-md-3
- = f.label :id, s_('Profiles|User ID')
- = f.text_field :id, class: 'gl-form-input form-control', readonly: true
- .form-group.gl-form-group
- = f.label :pronouns, s_('Profiles|Pronouns')
- = f.text_field :pronouns, class: 'gl-form-input form-control gl-md-form-input-lg'
- %small.form-text.text-gl-muted
- = s_("Profiles|Enter your pronouns to let people know how to refer to you.")
- .form-group.gl-form-group
- = f.label :pronunciation, s_('Profiles|Pronunciation')
- = f.text_field :pronunciation, class: 'gl-form-input form-control gl-md-form-input-lg'
- %small.form-text.text-gl-muted
- = s_("Profiles|Enter how your name is pronounced to help people address you correctly.")
- = render_if_exists 'profiles/extra_settings', form: f
- = render_if_exists 'profiles/email_settings', form: f
- .form-group.gl-form-group
- = f.label :skype
- = f.text_field :skype, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|username")
- .form-group.gl-form-group
- = f.label :linkedin
- = f.text_field :linkedin, class: 'gl-form-input form-control gl-md-form-input-lg'
- %small.form-text.text-gl-muted
- = s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename")
- .form-group.gl-form-group
- = f.label :twitter
- = f.text_field :twitter, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|@username")
- .form-group.gl-form-group
- - external_accounts_help_url = help_page_path('user/profile/index', anchor: 'add-external-accounts-to-your-user-profile-page')
- - external_accounts_link = link_to '', external_accounts_help_url, target: "_blank", rel: "noopener noreferrer"
- - external_accounts_docs_link = safe_format(s_('Profiles|Your Discord user ID. %{external_accounts_link_start}Learn more.%{external_accounts_link_end}'), tag_pair(external_accounts_link, :external_accounts_link_start, :external_accounts_link_end))
- - min_discord_length = 17
- - max_discord_length = 20
- = f.label :discord
- = f.text_field :discord,
- class: 'gl-form-input form-control gl-md-form-input-lg js-validate-length',
- placeholder: s_("Profiles|User ID"),
- data: { min_length: min_discord_length,
- min_length_message: s_('Profiles|Discord ID is too short (minimum is %{min_length} characters).') % { min_length: min_discord_length },
- max_length: max_discord_length,
- max_length_message: s_('Profiles|Discord ID is too long (maximum is %{max_length} characters).') % { max_length: max_discord_length },
- allow_empty: true}
- %small.form-text.text-gl-muted
- = external_accounts_docs_link
- .form-group.gl-form-group
- = f.label :website_url, s_('Profiles|Website url')
- = f.text_field :website_url, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|https://website.com")
- .form-group.gl-form-group
- = f.label :location, s_('Profiles|Location')
- - if @user.read_only_attribute?(:location)
- = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', readonly: true
- %small.form-text.text-gl-muted
- = s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) }
- - else
- = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|City, country")
- .form-group.gl-form-group
- = f.label :job_title, s_('Profiles|Job title')
- = f.text_field :job_title, class: 'gl-form-input form-control gl-md-form-input-lg'
- .form-group.gl-form-group
- = f.label :organization, s_('Profiles|Organization')
- = f.text_field :organization, class: 'gl-form-input form-control gl-md-form-input-lg'
- %small.form-text.text-gl-muted
- = s_("Profiles|Who you represent or work for.")
- .form-group.gl-form-group
- = f.label :bio, s_('Profiles|Bio')
- = f.text_area :bio, class: 'gl-form-input gl-form-textarea form-control', rows: 4, maxlength: 250
+ .settings-section.user-time-preferences.js-search-settings-section.gl-border-t.gl-pt-6
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0= s_("Profiles|Time settings")
+ %p.gl-text-secondary= s_("Profiles|Set your local time zone.")
+ = f.label :user_timezone, _("Time zone")
+ .js-timezone-dropdown{ data: { timezone_data: timezone_data.to_json, initial_value: @user.timezone, name: 'user[timezone]' } }
+
+ .settings-section.js-search-settings-section.gl-border-t.gl-pt-6
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = s_("Profiles|Main settings")
+ %p.gl-text-secondary
+ = s_("Profiles|This information will appear on your profile.")
+ - if current_user.ldap_user?
+ = s_("Profiles|Some options are unavailable for LDAP accounts")
+ .form-group.gl-form-group.rspec-full-name.gl-max-w-80
+ = render 'profiles/name', form: f, user: @user
+ .form-group.gl-form-group.gl-md-form-input-lg
+ = f.label :id, s_('Profiles|User ID')
+ = f.text_field :id, class: 'gl-form-input form-control', readonly: true
+ .form-group.gl-form-group
+ = f.label :pronouns, s_('Profiles|Pronouns')
+ = f.text_field :pronouns, class: 'gl-form-input form-control gl-md-form-input-lg'
+ %small.form-text.text-gl-muted
+ = s_("Profiles|Enter your pronouns to let people know how to refer to you.")
+ .form-group.gl-form-group
+ = f.label :pronunciation, s_('Profiles|Pronunciation')
+ = f.text_field :pronunciation, class: 'gl-form-input form-control gl-md-form-input-lg'
+ %small.form-text.text-gl-muted
+ = s_("Profiles|Enter how your name is pronounced to help people address you correctly.")
+ = render_if_exists 'profiles/extra_settings', form: f
+ = render_if_exists 'profiles/email_settings', form: f
+ .form-group.gl-form-group
+ = f.label :skype
+ = f.text_field :skype, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|username")
+ .form-group.gl-form-group
+ = f.label :linkedin
+ = f.text_field :linkedin, class: 'gl-form-input form-control gl-md-form-input-lg'
+ %small.form-text.text-gl-muted
+ = s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename")
+ .form-group.gl-form-group
+ = f.label :twitter
+ = f.text_field :twitter, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|@username")
+ .form-group.gl-form-group
+ - external_accounts_help_url = help_page_path('user/profile/index', anchor: 'add-external-accounts-to-your-user-profile-page')
+ - external_accounts_link = link_to '', external_accounts_help_url, target: "_blank", rel: "noopener noreferrer"
+ - external_accounts_docs_link = safe_format(s_('Profiles|Your Discord user ID. %{external_accounts_link_start}Learn more.%{external_accounts_link_end}'), tag_pair(external_accounts_link, :external_accounts_link_start, :external_accounts_link_end))
+ - min_discord_length = 17
+ - max_discord_length = 20
+ = f.label :discord
+ = f.text_field :discord,
+ class: 'gl-form-input form-control gl-md-form-input-lg js-validate-length',
+ placeholder: s_("Profiles|User ID"),
+ data: { min_length: min_discord_length,
+ min_length_message: s_('Profiles|Discord ID is too short (minimum is %{min_length} characters).') % { min_length: min_discord_length },
+ max_length: max_discord_length,
+ max_length_message: s_('Profiles|Discord ID is too long (maximum is %{max_length} characters).') % { max_length: max_discord_length },
+ allow_empty: true}
+ %small.form-text.text-gl-muted
+ = external_accounts_docs_link
+
+ .form-group.gl-form-group
+ = f.label :website_url, s_('Profiles|Website url')
+ = f.text_field :website_url, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|https://website.com")
+ .form-group.gl-form-group
+ = f.label :location, s_('Profiles|Location')
+ - if @user.read_only_attribute?(:location)
+ = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', readonly: true
%small.form-text.text-gl-muted
- = s_("Profiles|Tell us about yourself in fewer than 250 characters.")
- %hr
+ = s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) }
+ - else
+ = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|City, country")
+ .form-group.gl-form-group
+ = f.label :job_title, s_('Profiles|Job title')
+ = f.text_field :job_title, class: 'gl-form-input form-control gl-md-form-input-lg'
+ .form-group.gl-form-group
+ = f.label :organization, s_('Profiles|Organization')
+ = f.text_field :organization, class: 'gl-form-input form-control gl-md-form-input-lg'
+ %small.form-text.text-gl-muted
+ = s_("Profiles|Who you represent or work for.")
+ .form-group.gl-form-group.gl-mb-6.gl-max-w-80
+ = f.label :bio, s_('Profiles|Bio')
+ = f.text_area :bio, class: 'gl-form-input gl-form-textarea form-control', rows: 4, maxlength: 250
+ %small.form-text.text-gl-muted
+ = s_("Profiles|Tell us about yourself in fewer than 250 characters.")
+ .gl-border-t.gl-pt-6
%fieldset.form-group.gl-form-group
- %legend.col-form-label.col-form-label
+ %legend.col-form-label
= _('Private profile')
- private_profile_label = s_("Profiles|Don't display activity-related personal information on your profile.")
- private_profile_help_link = link_to sprite_icon('question-o'), help_page_path('user/profile/index.md', anchor: 'make-your-user-profile-page-private')
= f.gitlab_ui_checkbox_component :private_profile, '%{private_profile_label} %{private_profile_help_link}'.html_safe % { private_profile_label: private_profile_label, private_profile_help_link: private_profile_help_link.html_safe }
%fieldset.form-group.gl-form-group
- %legend.col-form-label.col-form-label
+ %legend.col-form-label
= s_("Profiles|Private contributions")
= f.gitlab_ui_checkbox_component :include_private_contributions,
s_('Profiles|Include private contributions on your profile'),
help_text: s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.")
- %fieldset.form-group.gl-form-group
- %legend.col-form-label.col-form-label
+ %fieldset.form-group.gl-form-group.gl-mb-0
+ %legend.col-form-label
= s_("Profiles|Achievements")
= f.gitlab_ui_checkbox_component :achievements_enabled,
s_('Profiles|Display achievements on your profile')
- .row.js-hide-when-nothing-matches-search
- .col-lg-12
- %hr
- = f.submit s_("Profiles|Update profile settings"), class: 'gl-mr-3 js-password-prompt-btn', pajamas_button: true
- = render Pajamas::ButtonComponent.new(href: user_path(current_user)) do
- = s_('TagsPage|Cancel')
+
+ .js-hide-when-nothing-matches-search.settings-sticky-footer
+ = f.submit s_("Profiles|Update profile settings"), class: 'gl-mr-3 js-password-prompt-btn', pajamas_button: true
+ = render Pajamas::ButtonComponent.new(href: user_path(current_user)) do
+ = s_('TagsPage|Cancel')
#password-prompt-modal
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 461164e1ae9..42297a0cf3d 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -55,7 +55,8 @@
= label_tag :pin_code, _('Enter verification code'), class: "label-bold"
= text_field_tag :pin_code, nil, autocomplete: 'off', inputmode: 'numeric', class: "form-control gl-form-input", required: true, data: { qa_selector: 'pin_code_field' }
.gl-mt-3
- = submit_tag _('Register with two-factor app'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'register_2fa_app_button' }
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { data: { qa_selector: 'register_2fa_app_button' } }) do
+ = _('Register with two-factor app')
%hr
@@ -101,7 +102,7 @@
- else
%span.gl-text-gray-500
= _("no name set")
- %td= registration[:created_at].to_date.to_s(:medium)
+ %td= registration[:created_at].to_date.to_fs(:medium)
%td
= render Pajamas::ButtonComponent.new(variant: :danger,
href: registration[:delete_path],
diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml
index 118f6fb1296..00da6c73081 100644
--- a/app/views/projects/_activity.html.haml
+++ b/app/views/projects/_activity.html.haml
@@ -3,8 +3,7 @@
.nav-block.d-none.d-sm-flex.activities.gl-static
= render 'shared/event_filter'
.controls.gl-display-flex
- = link_to project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip' do
- = sprite_icon('rss', css_class: 'gl-icon')
+ = link_button_to nil, project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'd-none d-sm-inline-flex has-tooltip', icon: 'rss'
- if is_project_overview && can?(current_user, :download_code, @project)
.project-clone-holder.d-none.d-md-inline-flex.gl-ml-2
= render "projects/buttons/clone", dropdown_class: 'dropdown-menu-right'
diff --git a/app/views/projects/_customize_workflow.html.haml b/app/views/projects/_customize_workflow.html.haml
index ded43a34b48..da4b257f224 100644
--- a/app/views/projects/_customize_workflow.html.haml
+++ b/app/views/projects/_customize_workflow.html.haml
@@ -5,4 +5,4 @@
%p
Get started with GitLab by enabling features that work best for your project. From issues and wikis, to merge requests and pipelines, GitLab can help manage your workflow from idea to production!
- if can?(current_user, :admin_project, @project)
- = link_to "Get started", edit_project_path(@project), class: "gl-button btn btn-confirm"
+ = link_button_to "Get started", edit_project_path(@project), variant: :confirm
diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml
index 97f5cdb54e5..3ef2c722e98 100644
--- a/app/views/projects/_export.html.haml
+++ b/app/views/projects/_export.html.haml
@@ -20,10 +20,12 @@
%li= _('Webhooks')
%li= _('Any encrypted tokens')
- if project.export_status == :finished
- = link_to _('Download export'), download_export_project_path(project),
- rel: 'nofollow', download: '', method: :get, class: "btn gl-button btn-default", data: { qa_selector: 'download_export_link' }
- = link_to _('Generate new export'), generate_new_export_project_path(project),
- method: :post, class: "btn gl-button btn-default"
+ = render Pajamas::ButtonComponent.new(href: download_export_project_path(project),
+ method: :get,
+ button_options: { ref: 'nofollow', download: '', data: { qa_selector: 'download_export_link' } }) do
+ = _('Download export')
+ = render Pajamas::ButtonComponent.new(href: generate_new_export_project_path(project), method: :post) do
+ = _('Generate new export')
- else
- = link_to _('Export project'), export_project_path(project),
- method: :post, class: "btn gl-button btn-default", data: { qa_selector: 'export_project_link' }
+ = render Pajamas::ButtonComponent.new(href: export_project_path(project), method: :post, button_options: { data: { qa_selector: 'export_project_link' } }) do
+ = _('Export project')
diff --git a/app/views/projects/_find_file_link.html.haml b/app/views/projects/_find_file_link.html.haml
index a4bf72edf12..4ad2c339bcc 100644
--- a/app/views/projects/_find_file_link.html.haml
+++ b/app/views/projects/_find_file_link.html.haml
@@ -1,2 +1,2 @@
-= link_to project_find_file_path(@project, @ref), class: 'gl-button btn btn-default shortcuts-find-file', rel: 'nofollow' do
+= link_button_to project_find_file_path(@project, @ref), class: 'shortcuts-find-file', rel: 'nofollow' do
= _('Find file')
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 9cb5ec39de2..59147138834 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -27,9 +27,7 @@
.project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-center.gl-flex-wrap.gl-gap-3
- if current_user
- if current_user.admin?
- = link_to [:admin, @project], class: 'btn btn-default gl-button btn-icon', title: _('View project in admin area'),
- data: {toggle: 'tooltip', placement: 'top', container: 'body'} do
- = sprite_icon('admin')
+ = link_button_to nil, [:admin, @project], icon: 'admin', title: _('View project in admin area'), data: {toggle: 'tooltip', placement: 'top', 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'), project_id: @project.id, no_flip: 'true' } }
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index 947a1007fd5..6315c6dc52d 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -43,9 +43,7 @@
- if gitea_import_enabled?
%div
- = link_to new_import_gitea_path(namespace_id: namespace_id), class: 'gl-button btn-default btn import_gitea js-import-project-btn', data: { platform: 'gitea', **tracking_attrs_data(track_label, 'click_button', 'gitea') } do
- .gl-button-icon
- = custom_icon('gitea_logo')
+ = render Pajamas::ButtonComponent.new(href: new_import_gitea_path(namespace_id: namespace_id), icon: 'gitea', button_options: { class: 'import_gitea js-import-project-btn', data: { platform: 'gitea', **tracking_attrs_data(track_label, 'click_button', 'gitea') } }) do
Gitea
- if git_import_enabled?
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index 6049d1cc110..983b8056358 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -98,4 +98,4 @@
-# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/675
= render_if_exists 'shared/other_project_options', f: f, visibility_level: visibility_level, track_label: track_label
= f.submit _('Create project'), class: "js-create-project-button", data: { qa_selector: 'project_create_button', track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" }, pajamas_button: true
-= link_to _('Cancel'), @parent_group || dashboard_groups_path, class: 'btn gl-button btn-default btn-cancel', data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" }
+= link_button_to _('Cancel'), @parent_group || dashboard_groups_path, data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" }
diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml
index 85a53edc160..c3d66396256 100644
--- a/app/views/projects/_readme.html.haml
+++ b/app/views/projects/_readme.html.haml
@@ -24,4 +24,4 @@
distributed with computer software, forming part of its documentation.
GitLab will render it here instead of this message.
%p
- = link_to "Add Readme", @project.add_readme_path, class: 'gl-button btn btn-confirm'
+ = link_button_to "Add Readme", @project.add_readme_path, variant: :confirm
diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml
index 14991ce3824..0a83efdb3b8 100644
--- a/app/views/projects/_service_desk_settings.html.haml
+++ b/app/views/projects/_service_desk_settings.html.haml
@@ -12,8 +12,8 @@
enabled: "#{@project.service_desk_enabled}",
issue_tracker_enabled: "#{@project.project_feature.issues_enabled?}",
incoming_email: (@project.service_desk_incoming_address if @project.service_desk_enabled),
- custom_email: (@project.service_desk_custom_address if @project.service_desk_enabled),
- custom_email_enabled: "#{Gitlab::Email::ServiceDeskEmail.enabled?}",
+ service_desk_email: (@project.service_desk_custom_address if @project.service_desk_enabled),
+ service_desk_email_enabled: "#{Gitlab::Email::ServiceDeskEmail.enabled?}",
selected_template: "#{@project.service_desk_setting&.issue_template_key}",
selected_file_template_project_id: "#{@project.service_desk_setting&.file_template_project_id}",
outgoing_name: "#{@project.service_desk_setting&.outgoing_name}",
diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml
index 45d0aee4332..e82e0972d82 100644
--- a/app/views/projects/_wiki.html.haml
+++ b/app/views/projects/_wiki.html.haml
@@ -14,4 +14,4 @@
- if can_create_wiki
%p
= _("Add a homepage to your wiki that contains information about your project and GitLab will display it here instead of this message.")
- = link_to _("Create your first page"), wiki_path(@project.wiki) + '?view=create', class: "btn gl-button btn-confirm"
+ = link_button_to _("Create your first page"), wiki_path(@project.wiki) + '?view=create', variant: :confirm
diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml
index ccda06c7e4c..ebeeaed7ae9 100644
--- a/app/views/projects/artifacts/browse.html.haml
+++ b/app/views/projects/artifacts/browse.html.haml
@@ -18,10 +18,8 @@
= link_to truncate(title, length: 40), browse_project_job_artifacts_path(@project, @build, path)
.tree-controls<
- = link_to download_project_job_artifacts_path(@project, @build),
- rel: 'nofollow', download: '', class: 'gl-button btn btn-default download' do
- = sprite_icon('download', css_class: 'gl-mr-2')
- Download artifacts archive
+ = link_button_to download_project_job_artifacts_path(@project, @build), rel: 'nofollow', download: '', class: 'download', icon: 'download' do
+ = _('Download artifacts archive')
.tree-content-holder
%table.table.tree-table
diff --git a/app/views/projects/artifacts/external_file.html.haml b/app/views/projects/artifacts/external_file.html.haml
index a014d134e31..67f6ccd5695 100644
--- a/app/views/projects/artifacts/external_file.html.haml
+++ b/app/views/projects/artifacts/external_file.html.haml
@@ -1,3 +1,4 @@
+- external_url = @blob.external_url(@build)
- page_title @path, _('Artifacts'), "#{@build.name} (##{@build.id})", _('Jobs')
= render "projects/jobs/header"
@@ -8,8 +9,8 @@
%h2= _("You are being redirected away from GitLab")
%p= _("This page is hosted on GitLab pages but contains user-generated content and may contain malicious code. Do not accept unless you trust the author and source.")
- = link_to @blob.external_url(@project, @build),
- @blob.external_url(@project, @build),
+ = link_to external_url,
+ external_url,
target: '_blank',
title: _('Opens in a new window'),
rel: 'noopener noreferrer'
diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml
index 79b13dc861a..417c11ba37a 100644
--- a/app/views/projects/blob/_breadcrumb.html.haml
+++ b/app/views/projects/blob/_breadcrumb.html.haml
@@ -22,14 +22,11 @@
-# only show normal/blame view links for text files
- if blob.readable_text?
- if blame
- = link_to 'Normal view', project_blob_path(@project, @id),
- class: 'gl-button btn btn-default'
+ = link_button_to _('Normal view'), project_blob_path(@project, @id)
- else
- = link_to 'Blame', project_blame_path(@project, @id),
- class: 'gl-button btn btn-default js-blob-blame-link' unless blob.empty?
+ = link_button_to _('Blame'), project_blame_path(@project, @id), class: 'js-blob-blame-link' unless blob.empty?
- = link_to 'History', project_commits_path(@project, @id),
- class: 'gl-button btn btn-default'
+ = link_button_to _('History'), project_commits_path(@project, @id)
- = link_to 'Permalink', project_blob_path(@project,
- tree_join(@commit.sha, @path)), class: 'gl-button btn btn-default js-data-file-blob-permalink-url'
+ = link_button_to _('Permalink'), project_blob_path(@project, tree_join(@commit.sha, @path)),
+ class: 'js-data-file-blob-permalink-url'
diff --git a/app/views/projects/blob/_new_dir.html.haml b/app/views/projects/blob/_new_dir.html.haml
index 3ae7741d24d..f1da9154df9 100644
--- a/app/views/projects/blob/_new_dir.html.haml
+++ b/app/views/projects/blob/_new_dir.html.haml
@@ -16,6 +16,6 @@
.form-actions
= submit_tag _("Create directory"), class: 'btn gl-button btn-confirm'
- = link_to _('Cancel'), '#', class: "btn gl-button btn-default btn-cancel", "data-dismiss" => "modal"
+ = link_button_to _('Cancel'), '#', "data-dismiss" => "modal"
= render 'shared/projects/edit_information'
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index adff64fad5a..ae8d230f356 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -11,13 +11,13 @@
= branch.name
= clipboard_button(text: branch.name, title: _("Copy branch name"))
- if is_default_branch
- = gl_badge_tag s_('DefaultBranchLabel|default'), { variant: :neutral, size: :sm }, { class: 'gl-ml-2', data: { qa_selector: 'badge_content' } }
+ = gl_badge_tag s_('DefaultBranchLabel|default'), { variant: :neutral, size: :sm }, { class: 'gl-ml-2' }
- if protected_branch?(@project, branch)
- = gl_badge_tag s_('Branches|protected'), { variant: :muted, size: :sm }, { class: 'gl-ml-2', data: { qa_selector: 'badge_content' } }
+ = gl_badge_tag s_('Branches|protected'), { variant: :muted, size: :sm }, { class: 'gl-ml-2' }
= render_if_exists 'projects/branches/diverged_from_upstream', branch: branch
- .block-truncated
+ .gl-text-truncate
- if commit
= render 'projects/branches/commit', commit: commit, project: @project
- else
@@ -28,35 +28,33 @@
.pipeline-status.d-none.d-md-block<
- if commit_status
- = render 'ci/status/icon', size: 16, status: commit_status, option_css_classes: 'gl-display-inline-flex gl-vertical-align-middle gl-mr-5'
+ = render 'ci/status/icon', size: 16, status: commit_status, option_css_classes: 'gl-display-inline-flex gl-vertical-align-middle gl-mr-3'
- elsif show_commit_status
- .gl-display-inline-flex.gl-vertical-align-middle.gl-mr-5
+ .gl-display-inline-flex.gl-vertical-align-middle.gl-mr-3
%svg.s16
-
- - if mr_status.present?
- .issuable-reference.gl-display-flex.gl-justify-content-end.gl-min-w-10.gl-ml-5.gl-mr-4
- = gl_badge_tag issuable_reference(related_merge_request),
- { icon: mr_status[:icon], variant: mr_status[:variant], size: :md, href: merge_request_path(related_merge_request) },
- { class: 'gl-mr-2', title: mr_status[:title], data: { toggle: 'tooltip', container: 'body', qa_selector: 'badge_content' } }
-
- .controls.d-none.d-md-block<
- - if mr_status.nil? && create_mr_button?(from: branch.name, source_project: @project)
- = render Pajamas::ButtonComponent.new(icon: 'merge-request', href: create_mr_path(from: branch.name, source_project: @project), button_options: { class: 'has-tooltip gl-mr-2!', title: _('New merge request') }) do
- = _('New')
-
- = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name], css_class: 'gl-mr-1!'
-
- - if !is_default_branch
- .js-branch-more-actions{ data: {
- branch_name: branch.name,
- default_branch_name: @repository.root_ref,
- can_delete_branch: user_access(@project).can_delete_branch?(branch.name).to_s,
- is_protected_branch: protected_branch?(@project, branch).to_s,
- merged: merged.to_s,
- compare_path: project_compare_index_path(@project, from: @repository.root_ref, to: branch.name),
- delete_path: project_branch_path(@project, branch.name),
- } }
- - else
- .gl-display-inline-flex.gl-w-7
- &nbsp;
+ .right-block.gl-display-flex.gl-align-items-center.gl-justify-content-end
+ .gl-mr-3
+ - if mr_status.present?
+ .issuable-reference.gl-display-flex.gl-justify-content-end.gl-overflow-hidden
+ = gl_badge_tag issuable_reference(related_merge_request),
+ { icon: mr_status[:icon], variant: mr_status[:variant], size: :md, href: merge_request_path(related_merge_request) },
+ { class: 'gl-display-block gl-text-truncate', title: mr_status[:title], data: { toggle: 'tooltip', container: 'body' } }
+
+ - elsif mr_status.nil? && create_mr_button?(from: branch.name, source_project: @project)
+ = render Pajamas::ButtonComponent.new(icon: 'merge-request', href: create_mr_path(from: branch.name, source_project: @project), button_options: { class: 'has-tooltip', title: _('New merge request') }) do
+ = _('New')
+
+ = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name], css_class: 'gl-mr-2!'
+
+ .gl-w-7
+ - if !is_default_branch
+ .js-branch-more-actions{ data: {
+ branch_name: branch.name,
+ default_branch_name: @repository.root_ref,
+ can_delete_branch: user_access(@project).can_delete_branch?(branch.name).to_s,
+ is_protected_branch: protected_branch?(@project, branch).to_s,
+ merged: merged.to_s,
+ compare_path: project_compare_index_path(@project, from: @repository.root_ref, to: branch.name),
+ delete_path: project_branch_path(@project, branch.name),
+ } }
diff --git a/app/views/projects/branches/_commit.html.haml b/app/views/projects/branches/_commit.html.haml
index 6bbd0617598..7662caceb15 100644
--- a/app/views/projects/branches/_commit.html.haml
+++ b/app/views/projects/branches/_commit.html.haml
@@ -1,7 +1,7 @@
-.branch-commit.gl-font-sm.gl-text-gray-500
+.branch-commit.gl-font-sm.gl-text-gray-500.gl-text-truncate
= link_to commit.short_id, project_commit_path(project, commit.id), class: "commit-sha"
&middot;
- %span.str-truncated
+ %span
= link_to_markdown commit.title, project_commit_path(project, commit.id), class: "commit-row-message gl-text-gray-500!"
&middot;
%span.gl-text-secondary= time_ago_with_tooltip(commit.committed_date)
diff --git a/app/views/projects/branches/_panel.html.haml b/app/views/projects/branches/_panel.html.haml
index a632e29d34f..c01e3677c19 100644
--- a/app/views/projects/branches/_panel.html.haml
+++ b/app/views/projects/branches/_panel.html.haml
@@ -7,9 +7,9 @@
- return unless branches.any?
-= render Pajamas::CardComponent.new(card_options: {class: 'gl-mt-5 gl-bg-gray-10'}, header_options: {class: 'gl-px-5 gl-py-4 gl-bg-white'}, body_options: {class: 'gl-px-3 gl-py-0'}, footer_options: {class: 'gl-bg-white'}) do |c|
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }, footer_options: { class: 'gl-new-card-footer' }) do |c|
- c.with_header do
- %h3.card-title.h5.gl-line-height-24.gl-m-0
+ %h3.gl-new-card-title.h5
= panel_title
- c.with_body do
%ul.content-list.branches-list.all-branches{ data: { qa_selector: 'all_branches_container' } }
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index 8992753c676..c03de6646cf 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -1,8 +1,6 @@
- add_page_specific_style 'page_bundles/branches'
- page_title _('Branches')
- add_to_breadcrumbs(_('Repository'), project_tree_path(@project))
-- can_access_branch_rules = can?(current_user, :maintainer_access, @project)
-- can_push_code = (can? current_user, :push_code, @project)
-# Possible values for variables passed down from the projects/branches_controller.rb
-#
@@ -24,21 +22,21 @@
sorted_by: @sort }
}
- - if can_access_branch_rules
- = link_to project_settings_repository_path(@project, anchor: 'js-branch-rules'), class: 'gl-button btn btn-default' do
+ - if can_view_branch_rules?
+ = link_button_to project_settings_repository_path(@project, anchor: 'js-branch-rules') do
= s_('Branches|View branch rules')
- - if can_push_code
- = link_to new_project_branch_path(@project), class: 'gl-button btn btn-confirm' do
+ - if can_push_code?
+ = link_button_to new_project_branch_path(@project), variant: :confirm do
= s_('Branches|New branch')
- .js-delete-merged-branches{ data: {
+ .js-delete-merged-branches.gl-w-7{ data: {
default_branch: @project.repository.root_ref,
form_path: project_merged_branches_path(@project) }
}
= render_if_exists 'projects/commits/mirror_status'
-- if can_access_branch_rules
+- if can_view_branch_rules?
= render 'branch_rules_info'
.js-branch-list{ data: { diverging_counts_endpoint: diverging_commit_counts_namespace_project_branches_path(@project.namespace, @project, format: :json), default_branch: @project.default_branch } }
diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml
index 9fd9943fd26..7f6a37fc210 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -20,4 +20,4 @@
= _('Existing branch name, tag, or commit SHA')
= render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { type: 'submit', class: 'gl-mr-3' }) do
= _('Create branch')
- = link_to _('Cancel'), project_branches_path(@project), class: 'gl-button btn btn-default btn-cancel'
+ = link_button_to _('Cancel'), project_branches_path(@project)
diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml
index ab026d9c6ac..db5d1ff5693 100644
--- a/app/views/projects/buttons/_clone.html.haml
+++ b/app/views/projects/buttons/_clone.html.haml
@@ -3,7 +3,7 @@
- if can?(current_user, :download_code, @project)
.git-clone-holder.js-git-clone-holder
- %a#clone-dropdown.gl-button.btn.btn-confirm.clone-dropdown-btn{ href: '#', data: { toggle: 'dropdown', qa_selector: 'clone_dropdown' } }
+ = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { id: 'clone-dropdown', class: 'clone-dropdown-btn', data: { toggle: 'dropdown', qa_selector: 'clone_dropdown' } }) do
%span.gl-mr-2.js-clone-dropdown-label
= _('Clone')
= sprite_icon("chevron-down", css_class: "icon")
diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml
index bbee7d66dcb..23d18236738 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -6,7 +6,7 @@
- if !project.empty_repo? && can?(current_user, :download_code, project)
- archive_prefix = "#{project.path}-#{ref.tr('/', '-')}"
.project-action-button.dropdown.gl-dropdown.inline{ class: css_class }>
- %button.gl-button.btn.btn-default.dropdown-toggle.gl-dropdown-toggle.dropdown-icon-only.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static', data: { qa_selector: 'download_source_code_button' } }
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'dropdown-toggle gl-dropdown-toggle dropdown-icon-only has-tooltip', title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static', data: { qa_selector: 'download_source_code_button' } }) do
= sprite_icon('download', css_class: 'gl-icon dropdown-icon')
%span.sr-only= _('Select Archive Format')
= sprite_icon('chevron-down', css_class: 'gl-icon dropdown-chevron')
diff --git a/app/views/projects/buttons/_download_links.html.haml b/app/views/projects/buttons/_download_links.html.haml
index d36aed44e18..31185fc1532 100644
--- a/app/views/projects/buttons/_download_links.html.haml
+++ b/app/views/projects/buttons/_download_links.html.haml
@@ -1,4 +1,4 @@
.btn-group.ml-0.w-100
- Gitlab::Workhorse::ARCHIVE_FORMATS.each_with_index do |fmt, index|
- archive_path = project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: fmt)
- = link_to fmt, external_storage_url_or_path(archive_path), rel: 'nofollow', download: '', class: "gl-button btn btn-sm #{index == 0 ? 'btn-confirm' : 'btn-default'}"
+ = link_button_to fmt, external_storage_url_or_path(archive_path), rel: 'nofollow', download: '', variant: index == 0 ? :confirm : :default, size: :small
diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml
index 6d05f1dc955..c9dcfaff8c6 100644
--- a/app/views/projects/buttons/_fork.html.haml
+++ b/app/views/projects/buttons/_fork.html.haml
@@ -2,17 +2,15 @@
- if current_user
.count-badge.btn-group
- if current_user.already_forked?(@project) && current_user.forkable_namespaces.size < 2
- = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: s_('ProjectOverview|Go to your fork'), class: 'gl-button btn btn-default has-tooltip fork-btn' do
- = sprite_icon('fork', css_class: 'icon')
- %span= s_('ProjectOverview|Fork')
+ = link_button_to namespace_project_path(current_user, current_user.fork_of(@project)), title: s_('ProjectOverview|Go to your fork'), class: 'has-tooltip fork-btn', icon: 'fork' do
+ = s_('ProjectOverview|Fork')
- else
- disabled_tooltip = fork_button_disabled_tooltip(@project)
- count_class = 'disabled' unless can?(current_user, :read_code, @project)
- 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}", 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
+ = link_button_to new_project_fork_path(@project), class: "fork-btn #{button_class}", data: { qa_selector: 'fork_button' }, icon: 'fork' do
+ = s_('ProjectOverview|Fork')
+ = link_button_to project_forks_path(@project), title: n_(s_('ProjectOverview|Forks'), s_('ProjectOverview|Forks'), @project.forks_count), class: "count has-tooltip fork-count #{count_class}" do
= @project.forks_count
diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml
index d4dcfbdff54..35318f68f57 100644
--- a/app/views/projects/buttons/_star.html.haml
+++ b/app/views/projects/buttons/_star.html.haml
@@ -6,12 +6,11 @@
.count-badge.d-inline-flex.align-item-stretch.btn-group
= render Pajamas::ButtonComponent.new(size: :medium, icon: icon, button_text_classes: button_text_classes, button_options: { class: 'star-btn toggle-star', data: { endpoint: toggle_star_project_path(@project, :json) } }) do
- button_text
- = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default has-tooltip star-count count' do
+ = link_button_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'has-tooltip star-count count' do
= @project.star_count
- else
.count-badge.d-inline-flex.align-item-stretch.btn-group
- = link_to new_user_session_path, class: 'gl-button btn btn-default has-tooltip star-btn', title: s_('ProjectOverview|You must sign in to star a project') do
- = sprite_icon('star-o', css_class: 'icon')
- %span= s_('ProjectOverview|Star')
- = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default has-tooltip star-count count' do
+ = link_button_to new_user_session_path, class: 'has-tooltip star-btn', title: s_('ProjectOverview|You must sign in to star a project'), icon: 'star-o' do
+ = s_('ProjectOverview|Star')
+ = link_button_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'has-tooltip star-count count' do
= @project.star_count
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index ecdd43a54f9..4017db459a9 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -103,33 +103,28 @@
.gl-text-right
.btn-group
- if can?(current_user, :read_job_artifacts, job) && job.artifacts?
- = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), class: 'gl-button btn btn-default btn-icon' do
- = sprite_icon('download', css_class: 'gl-icon')
+ = link_button_to nil, download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), icon: 'download'
- if can?(current_user, :update_build, job)
- if job.active?
- = link_to cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), class: 'gl-button btn btn-default btn-icon' do
- = sprite_icon('cancel', css_class: 'gl-icon')
+ = link_button_to nil, cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), icon: 'cancel'
- elsif job.scheduled?
- .gl-button.btn.btn-default.btn-icon.disabled{ disabled: true }
- = sprite_icon('planning', css_class: 'gl-icon')
+ = render Pajamas::ButtonComponent.new(disabled: true, icon: 'planning') do
%time.js-remaining-time{ datetime: job.scheduled_at.utc.iso8601 }
= duration_in_numbers(job.execute_in)
- confirmation_message = s_("DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after it's timer finishes.") % { job_name: job.name }
- = link_to play_project_job_path(job.project, job, return_to: request.original_url),
+ = link_button_to nil, play_project_job_path(job.project, job, return_to: request.original_url),
method: :post,
title: s_('DelayedJobs|Start now'),
- class: 'gl-button btn btn-default btn-icon has-tooltip',
- data: { confirm: confirmation_message } do
- = sprite_icon('play', css_class: 'gl-icon')
- = link_to unschedule_project_job_path(job.project, job, return_to: request.original_url),
+ class: 'has-tooltip',
+ data: { confirm: confirmation_message },
+ icon: 'play'
+ = link_button_to nil, unschedule_project_job_path(job.project, job, return_to: request.original_url),
method: :post,
title: s_('DelayedJobs|Unschedule'),
- class: 'gl-button btn btn-default btn-icon has-tooltip' do
- = sprite_icon('time-out', css_class: 'gl-icon')
+ class: 'has-tooltip',
+ icon: 'time-out'
- elsif allow_retry
- if job.playable? && !admin && can?(current_user, :update_build, job)
- = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), class: 'gl-button btn btn-default btn-icon' do
- = sprite_icon('play', css_class: 'gl-icon')
+ = link_button_to nil, play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), icon: 'play'
- elsif job.retryable?
- = link_to retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Retry'), class: 'gl-button btn btn-default btn-icon' do
- = sprite_icon('retry', css_class: 'gl-icon')
+ = link_button_to nil, retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Retry'), icon: 'retry'
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index c161e1c9d2a..24d063d3b4d 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -19,7 +19,7 @@
#{time_ago_with_tooltip(@commit.committed_date)}
#js-commit-comments-button{ data: { comments_count: @notes_count.to_i } }
- = link_to _('Browse files'), project_tree_path(@project, @commit), class: "btn gl-button btn-default gl-mr-3 gl-xs-w-full gl-xs-mb-3"
+ = link_button_to _('Browse files'), project_tree_path(@project, @commit), class: 'gl-mr-3 gl-xs-w-full gl-xs-mb-3'
#js-commit-options-dropdown{ data: commit_options_dropdown_data(@project, @commit) }
.commit-box{ data: { project_path: project_path(@project) } }
diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml
index f7ae462e8f9..382cb499fe1 100644
--- a/app/views/projects/commit/_pipelines_list.haml
+++ b/app/views/projects/commit/_pipelines_list.haml
@@ -4,6 +4,7 @@
#commit-pipeline-table-view{ data: { disable_initialization: disable_initialization,
endpoint: endpoint,
full_path: @project.full_path,
+ graphql_path: api_graphql_path,
"empty-state-svg-path" => image_path('illustrations/empty-state/empty-pipeline-md.svg'),
"error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'),
"project-id": @project.id,
diff --git a/app/views/projects/commit/_signature_badge_user.html.haml b/app/views/projects/commit/_signature_badge_user.html.haml
deleted file mode 100644
index 656adef6a72..00000000000
--- a/app/views/projects/commit/_signature_badge_user.html.haml
+++ /dev/null
@@ -1,21 +0,0 @@
-- user = signature.signed_by_user
-
-- if user
- = link_to user_path(user), class: 'gpg-popover-user-link' do
- %div
- = user_avatar_without_link(user: user, size: 32)
-
- %div
- %strong= user.name
- %div= user.to_reference
-- elsif signature.gpg? # SSH signatures do not have an email embedded in them
- - user_name = signature.gpg_key_user_name
- - user_email = signature.gpg_key_user_email
- - if user_name && user_email
- = mail_to user_email do
- %div
- = user_avatar_without_link(user_name: user_name, user_email: user_email, size: 32)
-
- %div
- %strong= user_name
- %div= user_email
diff --git a/app/views/projects/commit/_verified_system_signature_badge.html.haml b/app/views/projects/commit/_verified_system_signature_badge.html.haml
new file mode 100644
index 00000000000..96ff26ecbd7
--- /dev/null
+++ b/app/views/projects/commit/_verified_system_signature_badge.html.haml
@@ -0,0 +1,5 @@
+- title = _('Verified commit')
+- description = _('This commit was created in the GitLab UI, and signed with a GitLab-verified signature.')
+- locals = { signature: signature, title: title, description: description, label: _('Verified'), variant: 'success' }
+
+= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/x509/_signature_badge_user.html.haml b/app/views/projects/commit/x509/_signature_badge_user.html.haml
deleted file mode 100644
index da749172369..00000000000
--- a/app/views/projects/commit/x509/_signature_badge_user.html.haml
+++ /dev/null
@@ -1,19 +0,0 @@
-- user_email = signature.x509_certificate.email
-- user = signature.signed_by_user
-
-- if user
- = link_to user_path(user), class: 'gpg-popover-user-link' do
- %div
- = user_avatar_without_link(user: user, size: 32)
-
- %div
- %strong= user.name
- %div= user.to_reference
-
-- else
- = mail_to user_email do
- %div
- = user_avatar_without_link(user_email: user_email, size: 32)
-
- %div
- %strong= user_email
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index 6209ef48f96..13a406d442d 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -25,7 +25,7 @@
.avatar-cell.d-none.d-sm-block
= author_avatar(commit, size: 40, has_tooltip: false)
- .commit-detail.flex-list.gl-display-flex.gl-justify-content-space-between.gl-align-items-flex-start.gl-flex-grow-1.gl-min-w-0
+ .commit-detail.flex-list.gl-display-flex.gl-justify-content-space-between.gl-align-items-center.gl-flex-grow-1.gl-min-w-0
.commit-content{ data: { qa_selector: 'commit_content' } }
- if view_details && merge_request
= link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: ["commit-row-message item-title js-onboarding-commit-item", ("font-italic" if commit.message.empty?)]
diff --git a/app/views/projects/commits/_commit_list.html.haml b/app/views/projects/commits/_commit_list.html.haml
index 22f4594c1d5..721040f9a09 100644
--- a/app/views/projects/commits/_commit_list.html.haml
+++ b/app/views/projects/commits/_commit_list.html.haml
@@ -4,7 +4,7 @@
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5'}, body_options: { class: 'gl-py-0'}) do |c|
- c.with_header do
- Commits (#{@total_commit_count})
+ = s_('CompareRevisions|Commits on Source (%{commits_amount})').html_safe % { commits_amount: @total_commit_count }
- c.with_body do
- if hidden > 0
%ul.content-list
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index 4c5a9acdf83..8afc9ade3e1 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -18,7 +18,7 @@
.tree-controls
- if @merge_request.present?
.control.d-none.d-md-block
- = link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'btn gl-button'
+ = link_button_to _("View open merge request"), project_merge_request_path(@project, @merge_request)
- elsif create_mr_button?(from: @ref, source_project: @project)
.control.d-none.d-md-block
= render Pajamas::ButtonComponent.new(variant: :confirm, href: create_mr_path(from: @ref, source_project: @project)) do
@@ -28,8 +28,7 @@
= form_tag(project_commits_path(@project, @id, ref_type: @ref_type), method: :get, class: 'commits-search-form js-signature-container', data: { 'signatures-path' => namespace_project_signatures_path(ref_type: @ref_type)}) do
= search_field_tag :search, params[:search], { placeholder: _('Search by message'), id: 'commits-search', class: 'form-control gl-form-input input-short gl-mt-3 gl-sm-mt-0 gl-min-w-full', spellcheck: false }
.control.d-none.d-md-block
- = link_to project_commits_path(@project, @id, rss_url_options), title: _("Commits feed"), class: 'btn gl-button btn-default btn-icon' do
- = sprite_icon('rss')
+ = link_button_to nil, project_commits_path(@project, @id, rss_url_options), title: _("Commits feed"), icon: 'rss'
= render_if_exists 'projects/commits/mirror_status'
diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml
index 58da76a3231..4a29402bfe7 100644
--- a/app/views/projects/compare/index.html.haml
+++ b/app/views/projects/compare/index.html.haml
@@ -1,16 +1,6 @@
-- breadcrumb_title _("Compare revisions")
-- page_title _("Compare revisions")
+- breadcrumb_title s_("CompareRevisions|Compare revisions")
-%h1.page-title.gl-font-size-h-display
- = _("Compare Git revisions")
-%div
- - example_branch = capture do
- %code.ref-name= @project.default_branch_or_main
- - example_sha = capture do
- %code.ref-name 4eedf23
- = html_escape(_("To see what's changed or create a merge request, choose a branch or tag (like %{branch}), or enter a commit (like %{sha}).")) % { branch: example_branch.html_safe, sha: example_sha.html_safe }
- %br
- = html_escape(_("Changes are shown as if the %{b_open}source%{b_close} revision was being merged into the %{b_open}target%{b_close} revision.")) % { b_open: '<b>'.html_safe, b_close: '</b>'.html_safe }
+- page_title _("CompareRevisions|Compare revisions")
.prepend-top-20
#js-compare-selector{ data: project_compare_selector_data(@project, @merge_request, @compare_params) }
diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml
index 9185afc0771..5b6f7c392dd 100644
--- a/app/views/projects/compare/show.html.haml
+++ b/app/views/projects/compare/show.html.haml
@@ -1,7 +1,7 @@
-- add_to_breadcrumbs _("Compare revisions"), project_compare_index_path(@project)
-- page_title "#{params[:from]}...#{params[:to]}"
+- add_to_breadcrumbs s_("CompareRevisions|Compare revisions"), project_compare_index_path(@project)
+- page_title "#{params[:from]} to #{params[:to]}"
-.sub-header-block.gl-border-b-0.gl-mb-0
+.sub-header-block.gl-border-b-0.gl-mb-0.gl-pt-4
.js-signature-container{ data: { 'signatures-path' => signatures_namespace_project_compare_index_path } }
#js-compare-selector{ data: project_compare_selector_data(@project, @merge_request, params) }
@@ -20,13 +20,13 @@
= render Pajamas::CardComponent.new(card_options: { class: "gl-bg-gray-50 gl-mb-5 gl-border-none gl-text-center" }) do |c|
- c.with_body do
%h4
- = s_("CompareBranches|There isn't anything to compare.")
+ = s_("CompareRevisions|There isn't anything to compare.")
%p.gl-mb-4.gl-line-height-24
- if params[:to] == params[:from]
- source_branch = capture do
%span.ref-name= params[:from]
- target_branch = capture do
%span.ref-name= params[:to]
- = (s_("CompareBranches|%{source_branch} and %{target_branch} are the same.") % { source_branch: source_branch, target_branch: target_branch }).html_safe
+ = (s_("CompareRevisions|%{source_branch} and %{target_branch} are the same.") % { source_branch: source_branch, target_branch: target_branch }).html_safe
- else
= _("You'll need to use different branch names to get a valid comparison.")
diff --git a/app/views/projects/confluences/show.html.haml b/app/views/projects/confluences/show.html.haml
index 6fec9b501ea..283408ffa63 100644
--- a/app/views/projects/confluences/show.html.haml
+++ b/app/views/projects/confluences/show.html.haml
@@ -8,6 +8,6 @@
- wiki_confluence_epic_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/3629'
- wiki_confluence_epic_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wiki_confluence_epic_link_url }
= html_escape(s_("WikiEmpty|You've enabled the Confluence Workspace integration. Your wiki will be viewable directly within Confluence. We are hard at work integrating Confluence more seamlessly into GitLab. If you'd like to stay up to date, follow our %{wiki_confluence_epic_link_start}Confluence epic%{wiki_confluence_epic_link_end}.")) % { wiki_confluence_epic_link_start: wiki_confluence_epic_link_start, wiki_confluence_epic_link_end: '</a>'.html_safe }
- = link_to @project.confluence_integration.confluence_url, target: '_blank', rel: 'noopener noreferrer', class: 'gl-button btn btn-confirm external-url', title: s_('WikiEmpty|Go to Confluence') do
+ = link_button_to @project.confluence_integration.confluence_url, target: '_blank', rel: 'noopener noreferrer', class: 'external-url', title: s_('WikiEmpty|Go to Confluence'), variant: :confirm do
= s_('WikiEmpty|Go to Confluence')
= sprite_icon('external-link')
diff --git a/app/views/projects/deploy_keys/edit.html.haml b/app/views/projects/deploy_keys/edit.html.haml
index 91444a00334..997443d5fa9 100644
--- a/app/views/projects/deploy_keys/edit.html.haml
+++ b/app/views/projects/deploy_keys/edit.html.haml
@@ -7,4 +7,4 @@
= render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
.form-actions
= f.submit _('Save changes'), pajamas_button: true
- = link_to _('Cancel'), project_settings_repository_path(@project), class: 'gl-button btn btn-default btn-cancel'
+ = link_button_to _('Cancel'), project_settings_repository_path(@project)
diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml
index 982ecbbae51..9193fc4ef25 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -12,7 +12,7 @@
.files-changed-inner
.inline-parallel-buttons.gl-display-none.gl-md-display-flex.gl-relative
- if !diffs_expanded? && diff_files.any?(&:collapsed?)
- = link_to _('Expand all'), url_for(safe_params.merge(expanded: 1, format: nil)), class: 'gl-button btn btn-default'
+ = link_button_to _('Expand all'), url_for(safe_params.merge(expanded: 1, format: nil))
- if show_whitespace_toggle
- if current_controller?(:commit)
= commit_diff_whitespace_link(diffs.project, @commit, class: 'd-none d-sm-inline-block')
diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml
index 5ec95c3095d..3db1467df60 100644
--- a/app/views/projects/diffs/_file.html.haml
+++ b/app/views/projects/diffs/_file.html.haml
@@ -18,9 +18,7 @@
#js-diff-stats{ data: diff_file_stats_data(diff_file) }
- if diff_file.blob&.readable_text?
- unless @diff_notes_disabled
- %span.has-tooltip{ title: _("Toggle comments for this file") }
- = link_to '#', class: 'js-toggle-diff-comments btn gl-button btn-default btn-icon selected' do
- = sprite_icon('comment')
+ = link_button_to nil, '#', class: 'js-toggle-diff-comments has-tooltip', icon: 'comment', title: _("Toggle comments for this file")
\
- if editable_diff?(diff_file)
- link_opts = @merge_request.persisted? ? { from_merge_request_iid: @merge_request.iid } : {}
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index a5224db1be9..98e8c2dd61b 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -7,12 +7,10 @@
= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project
-- if Feature.enabled?(:show_pages_in_deployments_menu, current_user, type: :experiment)
- = render Pajamas::AlertComponent.new(variant: :info,
- title: _('GitLab Pages has moved'),
- alert_options: { class: 'gl-my-5', data: { feature_id: Users::CalloutsHelper::PAGES_MOVED_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' } }) do |c|
- - c.with_body do
- = _('To go to GitLab Pages, on the left sidebar, select %{pages_link}.').html_safe % {pages_link: link_to('Deployments > Pages', project_pages_path(@project)).html_safe}
+= render Pajamas::AlertComponent.new(title: _('GitLab Pages has moved'),
+ alert_options: { class: 'gl-my-5', data: { feature_id: Users::CalloutsHelper::PAGES_MOVED_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' } }) do |c|
+ - c.with_body do
+ = _('To go to GitLab Pages, on the left sidebar, select %{pages_link}.').html_safe % {pages_link: link_to('Deploy > Pages', project_pages_path(@project)).html_safe}
%section.settings.general-settings.no-animate.expanded#js-general-settings
.settings-header
@@ -79,8 +77,8 @@
%p
= _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.')
= link_to _('Learn more.'), help_page_path('administration/housekeeping'), target: '_blank', rel: 'noopener noreferrer'
- = link_to _('Run housekeeping'), housekeeping_project_path(@project),
- method: :post, class: "btn gl-button btn-default"
+ = render Pajamas::ButtonComponent.new(method: :post, href: housekeeping_project_path(@project)) do
+ = _('Run housekeeping')
.gl-display-inline-flex
#js-project-prune-unreachable-objects-button{ data: { prune_objects_path: housekeeping_project_path(@project, prune: true), prune_objects_doc_path: help_page_path('administration/housekeeping', anchor: 'prune-unreachable-objects') } }
diff --git a/app/views/projects/environments/edit.html.haml b/app/views/projects/environments/edit.html.haml
index c7752a45c63..1c107784e08 100644
--- a/app/views/projects/environments/edit.html.haml
+++ b/app/views/projects/environments/edit.html.haml
@@ -2,7 +2,7 @@
- add_page_specific_style 'page_bundles/environments'
#js-edit-environment{ data: { project_environments_path: project_environments_path(@project),
- update_environment_path: project_environment_path(@project, @environment),
protected_environment_settings_path: (project_settings_ci_cd_path(@project, anchor: 'js-protected-environments-settings') if @project.licensed_feature_available?(:protected_environments)),
project_path: @project.full_path,
- environment: environment_data(@environment) } }
+ environment_name: @environment.name,
+ kas_tunnel_url: ::Gitlab::Kas.tunnel_url } }
diff --git a/app/views/projects/environments/empty_metrics.html.haml b/app/views/projects/environments/empty_metrics.html.haml
deleted file mode 100644
index df05909e8ef..00000000000
--- a/app/views/projects/environments/empty_metrics.html.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-- page_title _("Metrics")
-
-.row.empty-state
- .col-sm-12
- .svg-content
- = image_tag 'illustrations/operations_metrics_empty.svg'
- .col-12
- .text-content
- %h4.text-center
- = s_('Environments|No deployed environments')
- %p.state-description
- = s_('Metrics|Check out the CI/CD documentation on deploying to an environment')
- .text-center
- = link_to s_("Environments|Learn about environments"), help_page_path('ci/environments/index.md'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml
deleted file mode 100644
index 31041d124e4..00000000000
--- a/app/views/projects/environments/metrics.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-- add_page_specific_style 'page_bundles/prometheus'
-
-- page_title _("Metrics Dashboard"), @environment.name
-
-.prometheus-container
- #prometheus-graphs{ data: metrics_data(@project, @environment) }
diff --git a/app/views/projects/environments/new.html.haml b/app/views/projects/environments/new.html.haml
index 9e8484b88b9..301c19ee6f0 100644
--- a/app/views/projects/environments/new.html.haml
+++ b/app/views/projects/environments/new.html.haml
@@ -3,4 +3,4 @@
- page_title s_("Environments|New Environment")
- add_page_specific_style 'page_bundles/environments'
-#js-new-environment{ data: { project_environments_path: project_environments_path(@project), project_path: @project.full_path, } }
+#js-new-environment{ data: { project_environments_path: project_environments_path(@project), project_path: @project.full_path, kas_tunnel_url: ::Gitlab::Kas.tunnel_url } }
diff --git a/app/views/projects/environments/terminal.html.haml b/app/views/projects/environments/terminal.html.haml
index 7c837d4ded0..c2ad9191800 100644
--- a/app/views/projects/environments/terminal.html.haml
+++ b/app/views/projects/environments/terminal.html.haml
@@ -13,8 +13,7 @@
.col-sm-6
.nav-controls
- if @environment.external_url.present?
- = link_to @environment.external_url, class: 'gl-button btn btn-default', target: '_blank', rel: 'noopener noreferrer nofollow' do
- = sprite_icon('external-link')
+ = link_button_to nil, @environment.external_url, target: '_blank', rel: 'noopener noreferrer nofollow', icon: 'external-link'
= render 'projects/deployments/actions', deployment: @environment.last_deployment
.terminal-container{ class: container_class }
diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml
index afb49c48146..7e93e44c463 100644
--- a/app/views/projects/find_file/show.html.haml
+++ b/app/views/projects/find_file/show.html.haml
@@ -2,11 +2,11 @@
- add_page_specific_style 'page_bundles/tree'
.file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @ref)) }
- .nav-block
- .tree-ref-holder
+ .nav-block.gl-xs-mr-0
+ .tree-ref-holder.gl-xs-mb-3.gl-xs-w-full
#js-blob-ref-switcher{ data: { project_id: @project.id, ref: @ref, namespace: "/-/find_file" } }
- %ul.breadcrumb.repo-breadcrumb
- %li.breadcrumb-item
+ %ul.breadcrumb.repo-breadcrumb.gl-flex-nowrap
+ %li.breadcrumb-item.gl-white-space-nowrap
= link_to project_tree_path(@project, @ref) do
= @project.path
%li.file-finder.breadcrumb-item
diff --git a/app/views/projects/forks/error.html.haml b/app/views/projects/forks/error.html.haml
index cff5899b960..f589c8f9566 100644
--- a/app/views/projects/forks/error.html.haml
+++ b/app/views/projects/forks/error.html.haml
@@ -18,4 +18,4 @@
= error
- c.with_actions do
- = link_to _('Try to fork again'), new_project_fork_path(@project), title: _("Fork"), class: "btn gl-alert-action btn-info btn-md gl-button"
+ = link_button_to _('Try to fork again'), new_project_fork_path(@project), title: _("Fork"), class: 'gl-alert-action', variant: :confirm
diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml
index d28ee30b6f9..49047749b71 100644
--- a/app/views/projects/forks/index.html.haml
+++ b/app/views/projects/forks/index.html.haml
@@ -20,12 +20,10 @@
- if current_user && can?(current_user, :fork_project, @project)
- if current_user.already_forked?(@project) && current_user.forkable_namespaces.size < 2
- = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn gl-button btn-confirm gl-md-ml-3' do
- = sprite_icon('fork', size: 12)
- %span= _('Fork')
+ = link_button_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'gl-md-ml-3', variant: :confirm, icon: 'fork' do
+ = _('Fork')
- else
- = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn gl-button btn-confirm gl-md-ml-3 gl-mt-3 gl-md-mt-0' do
- = sprite_icon('fork', size: 12)
- %span= _('Fork')
+ = link_button_to new_project_fork_path(@project), title: _("Fork project"), class: 'gl-md-ml-3 gl-mt-3 gl-md-mt-0', variant: :confirm, icon: 'fork' do
+ = _('Fork')
= render 'projects', projects: @forks
diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml
index 0f4dc4b5e32..30084e3310b 100644
--- a/app/views/projects/hook_logs/show.html.haml
+++ b/app/views/projects/hook_logs/show.html.haml
@@ -9,6 +9,6 @@
- if @hook_log.oversize?
= button_tag _("Resend Request"), class: "btn gl-button btn-default float-right gl-ml-3 has-tooltip", disabled: true, title: _("Request data is too large")
- else
- = link_to _("Resend Request"), @hook_log.present.retry_path, method: :post, class: "btn gl-button btn-default float-right gl-ml-3"
+ = link_button_to _("Resend Request"), @hook_log.present.retry_path, method: :post, class: 'float-right gl-ml-3'
= render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log }
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
index b553249c4b8..26ec09c76db 100644
--- a/app/views/projects/hooks/edit.html.haml
+++ b/app/views/projects/hooks/edit.html.haml
@@ -3,17 +3,16 @@
= render 'shared/web_hooks/hook_errors', hook: @hook
-.row.gl-mt-3
- .col-lg-3
- = render 'shared/web_hooks/title_and_docs', hook: @hook
+.gl-mt-5
+ = render 'shared/web_hooks/title_and_docs', hook: @hook
- .col-lg-9.gl-mb-3
- = gitlab_ui_form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook), html: { class: 'js-webhook-form' } do |f|
- = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
+ = gitlab_ui_form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook), html: { class: 'js-webhook-form' } do |f|
+ = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
- = f.submit _('Save changes'), pajamas_button: true
+ %div
+ = f.submit _('Save changes'), pajamas_button: true, class: 'gl-sm-mr-3'
= render 'shared/web_hooks/test_button', hook: @hook
- = link_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'btn gl-button btn-danger float-right', aria: { label: s_('Webhooks|Delete webhook') }, data: { confirm: s_('Webhooks|Are you sure you want to delete this project hook?'), confirm_btn_variant: 'danger' }
+ = link_button_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'gl-float-right', aria: { label: s_('Webhooks|Delete webhook') }, data: { confirm: s_('Webhooks|Are you sure you want to delete this project hook?'), confirm_btn_variant: 'danger' }, variant: :danger
%hr
diff --git a/app/views/projects/hooks/index.html.haml b/app/views/projects/hooks/index.html.haml
index 4d71161c96e..b57adc0ef0d 100644
--- a/app/views/projects/hooks/index.html.haml
+++ b/app/views/projects/hooks/index.html.haml
@@ -2,13 +2,6 @@
- page_title _('Webhooks')
- @force_desktop_expanded_sidebar = true
-.row.gl-mt-3.js-search-settings-section
- .col-lg-4
- = render 'shared/web_hooks/title_and_docs', hook: @hook
-
- .col-lg-8.gl-mb-3
- = gitlab_ui_form_for @hook, as: :hook, url: polymorphic_path([@project, :hooks]), html: { class: 'js-webhook-form' } do |f|
- = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
- = f.submit _('Add webhook'), pajamas_button: true, data: { qa_selector: "create_webhook_button" }
-
- = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class
+.gl-mt-3.js-search-settings-section
+ = render 'shared/web_hooks/title_and_docs', hook: @hook
+ = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class, partial: 'shared/web_hooks/form', url: polymorphic_path([@project, :hooks])
diff --git a/app/views/projects/integrations/shimos/show.html.haml b/app/views/projects/integrations/shimos/show.html.haml
index 92b9e03d5bd..e6cd8c15809 100644
--- a/app/views/projects/integrations/shimos/show.html.haml
+++ b/app/views/projects/integrations/shimos/show.html.haml
@@ -6,5 +6,5 @@
= s_('Shimo|Shimo Workspace integration is enabled')
%p
= s_("Shimo|You've enabled the Shimo Workspace integration. You can view your wiki directly in Shimo.")
- = link_to @project.shimo_integration.external_wiki_url, target: '_blank', rel: 'noopener noreferrer', class: 'gl-button btn btn-confirm', title: s_('Shimo|Go to Shimo Workspace') do
+ = link_button_to @project.shimo_integration.external_wiki_url, target: '_blank', rel: 'noopener noreferrer', title: s_('Shimo|Go to Shimo Workspace'), variant: :confirm do
= s_('Shimo|Go to Shimo Workspace')
diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml
index 3d6a266dc4d..21f1a4d19fa 100644
--- a/app/views/projects/issues/_related_branches.html.haml
+++ b/app/views/projects/issues/_related_branches.html.haml
@@ -1,24 +1,25 @@
- if @related_branches.any?
- if @related_branches.any?
- = render Pajamas::CardComponent.new(card_options: { class: 'gl-bg-gray-10 gl-mt-5 gl-mb-0' }, header_options: { class: 'gl-bg-white gl-pl-5 gl-pr-4 gl-py-4' } , body_options: { class: 'gl-py-3 gl-px-4' }) do |c|
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header' } , body_options: { class: 'gl-new-card-body' }) do |c|
- c.with_header do
- %h3.card-title.h5.gl-my-0.gl-display-flex.gl-align-items-center.gl-flex-grow-1.gl-relative.gl-line-height-24
- = link_to "", "#related-branches", class: "gl-link anchor position-absolute gl-text-decoration-none", "aria-hidden": true
- = _('Related branches')
- .gl-display-inline-flex.gl-mx-3.gl-text-gray-500
- .gl-display-inline-flex.gl-align-items-center
- = sprite_icon('branch', css_class: "gl-mr-2 gl-text-gray-500 gl-icon")
- = @related_branches.size
+ .gl-new-card-title-wrapper
+ %h3.gl-new-card-title
+ = link_to "", "#related-branches", class: "gl-link anchor position-absolute gl-text-decoration-none", "aria-hidden": true
+ = _('Related branches')
+ .gl-new-card-count
+ = sprite_icon('branch', css_class: "gl-mr-2 gl-text-gray-500 gl-icon")
+ = @related_branches.size
- c.with_body do
- %ul.related-merge-requests.content-list.gl-p-3!
- - @related_branches.each do |branch|
- %li.list-item{ class: "gl-py-0! gl-border-0!" }
- .item-body.gl-display-flex.align-items-center.gl-px-3.gl-pr-2.gl-mx-n2
- .item-contents.gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-flex-grow-1.gl-min-h-7
- .item-title.gl-display-flex.mb-xl-0.gl-min-w-0
- - if branch[:pipeline_status].present?
- %span.related-branch-ci-status
- = render 'ci/status/icon', status: branch[:pipeline_status]
- %span.related-branch-info
- %strong
- = link_to branch[:name], branch[:link], class: "ref-name"
+ .gl-new-card-content
+ %ul.related-merge-requests.content-list
+ - @related_branches.each do |branch|
+ %li.list-item{ class: "gl-py-0! gl-border-0!" }
+ .item-body.gl-display-flex.align-items-center.gl-px-3.gl-pr-2.gl-mx-n2
+ .item-contents.gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-flex-grow-1.gl-min-h-7
+ .item-title.gl-display-flex.mb-xl-0.gl-min-w-0
+ - if branch[:pipeline_status].present?
+ %span.related-branch-ci-status
+ = render 'ci/status/icon', status: branch[:pipeline_status]
+ %span.related-branch-info
+ %strong
+ = link_to branch[:name], branch[:link], class: "ref-name"
diff --git a/app/views/projects/issues/service_desk.html.haml b/app/views/projects/issues/service_desk.html.haml
index 3cc419716e5..9793f21e4a9 100644
--- a/app/views/projects/issues/service_desk.html.haml
+++ b/app/views/projects/issues/service_desk.html.haml
@@ -8,15 +8,26 @@
- support_bot_attrs = { service_desk_enabled: @project.service_desk_enabled?, **UserSerializer.new.represent(User.support_bot) }.to_json
.js-service-desk-issues.service-desk-issues{ data: { support_bot: support_bot_attrs } }
- .top-area
- = render 'shared/issuable/nav', type: :issues
- .nav-controls.d-block.d-sm-none
- = render "projects/issues/service_desk/nav_btns", show_feed_buttons: false, show_import_button: false, show_export_button: false
+ - if ::Feature.enabled?(:service_desk_vue_list, @project)
+ .js-service-desk-list{ data: { project_data: project_issues_list_data(@project, current_user),
+ service_desk_email_address: @project.service_desk_address,
+ can_admin_issues: can?(current_user, :admin_issue, @project).to_s,
+ can_edit_project_settings: can?(current_user, :admin_project, @project).to_s,
+ service_desk_callout_svg_path: image_path('service_desk_callout.svg'),
+ service_desk_settings_path: edit_project_path(@project, anchor: 'js-service-desk'),
+ service_desk_help_path: help_page_path('user/project/service_desk'),
+ is_service_desk_supported: Gitlab::ServiceDesk.supported?.to_s,
+ is_service_desk_enabled: @project.service_desk_enabled?.to_s } }
+ - else
+ .top-area
+ = render 'shared/issuable/nav', type: :issues
+ .nav-controls.gl-display-block.gl-sm-display-none
+ = render "projects/issues/service_desk/nav_btns", show_feed_buttons: false, show_import_button: false, show_export_button: false
- - if @issues.present?
- = render 'shared/issuable/search_bar', type: :issues
- - if Gitlab::ServiceDesk.supported?
- = render 'projects/issues/service_desk/service_desk_info_content'
+ - if @issues.present?
+ = render 'shared/issuable/search_bar', type: :issues
+ - if Gitlab::ServiceDesk.supported?
+ = render 'projects/issues/service_desk/service_desk_info_content'
- .issues-holder
- = render 'projects/issues/issues', empty_state_path: 'projects/issues/service_desk/service_desk_empty_state'
+ .issues-holder
+ = render 'projects/issues/issues', empty_state_path: 'projects/issues/service_desk/service_desk_empty_state'
diff --git a/app/views/projects/issues/service_desk/_issue.html.haml b/app/views/projects/issues/service_desk/_issue.html.haml
index 04ea6103b83..5b98712d3eb 100644
--- a/app/views/projects/issues/service_desk/_issue.html.haml
+++ b/app/views/projects/issues/service_desk/_issue.html.haml
@@ -33,7 +33,7 @@
%span.issuable-due-date.d-none.d-sm-inline-block.has-tooltip{ class: "#{'cred' if issue.overdue? && !issue.closed?}", title: _('Due date') }
&nbsp;
= sprite_icon('calendar')
- = issue.due_date.to_s(:medium)
+ = issue.due_date.to_fs(:medium)
= render_if_exists "projects/issues/issue_weight", issue: issue
= render_if_exists "projects/issues/health_status", issue: issue
diff --git a/app/views/projects/issues/service_desk/_nav_btns.html.haml b/app/views/projects/issues/service_desk/_nav_btns.html.haml
index a0a290f340a..3b7b3f57abd 100644
--- a/app/views/projects/issues/service_desk/_nav_btns.html.haml
+++ b/app/views/projects/issues/service_desk/_nav_btns.html.haml
@@ -7,12 +7,13 @@
.nav-controls.issues-nav-controls.gl-font-size-0
- if @can_bulk_update
- = button_tag _("Bulk edit"), class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle"
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'gl-mr-3 js-bulk-update-toggle' }) do
+ = _("Bulk edit")
- if show_new_issue_link?(@project)
- = link_to _("New issue"), new_project_issue_path(@project,
- issue: { milestone_id: finder.milestones.first.try(:id) }),
- class: "gl-button btn btn-confirm gl-mr-3",
- id: "new_issue_link"
+ = render Pajamas::ButtonComponent.new(variant: :confirm,
+ href: new_project_issue_path(@project, issue: { milestone_id: finder.milestones.first.try(:id) }),
+ button_options: { id: 'new_issue_link', class: 'gl-mr-3' }) do
+ = _("New issue")
.dropdown.gl-dropdown
= button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret has-tooltip gl-display-none! gl-md-display-inline-flex!", data: { toggle: 'dropdown', title: _('Actions') } do
diff --git a/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml b/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml
index 855625368a9..831bd107961 100644
--- a/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml
+++ b/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml
@@ -21,7 +21,7 @@
- if can_edit_project_settings && !service_desk_enabled
.text-center
- = link_to s_("ServiceDesk|Enable Service Desk"), edit_project_path(@project), class: 'gl-button btn btn-confirm'
+ = link_button_to s_("ServiceDesk|Enable Service Desk"), edit_project_path(@project), variant: :confirm
- else
.empty-state
.svg-content
diff --git a/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml b/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml
index 95837748c7f..093a47e63be 100644
--- a/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml
+++ b/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml
@@ -21,4 +21,4 @@
- if can_edit_project_settings && !service_desk_enabled
.gl-mt-3
- = link_to s_("ServiceDesk|Enable Service Desk"), edit_project_path(@project), class: 'gl-button btn btn-confirm'
+ = link_button_to s_("ServiceDesk|Enable Service Desk"), edit_project_path(@project), variant: :confirm
diff --git a/app/views/projects/jobs/_table.html.haml b/app/views/projects/jobs/_table.html.haml
index 954c77a21f3..0bb512b4035 100644
--- a/app/views/projects/jobs/_table.html.haml
+++ b/app/views/projects/jobs/_table.html.haml
@@ -12,7 +12,7 @@
= s_('Jobs|Use jobs to automate your tasks')
%p
= s_('Jobs|Jobs are the building blocks of a GitLab CI/CD pipeline. Each job has a specific task, like testing code. To set up jobs in a CI/CD pipeline, add a CI/CD configuration file to your project.')
- = link_to s_('Jobs|Create CI/CD configuration file'), project_ci_pipeline_editor_path(project), class: 'btn gl-button btn-confirm js-empty-state-button'
+ = link_button_to s_('Jobs|Create CI/CD configuration file'), project_ci_pipeline_editor_path(project), class: 'js-empty-state-button', variant: :confirm
- else
.nothing-here-block= s_('Jobs|No jobs to show')
- else
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
index 5f249f693ff..b151c355b3e 100644
--- a/app/views/projects/jobs/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -7,4 +7,4 @@
= render_if_exists "shared/shared_runners_minutes_limit_flash_message"
-#js-job-page{ data: jobs_data }
+#js-job-page{ data: jobs_data(@project, @build) }
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index 7a4ae409ee2..e1c904d000f 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -16,12 +16,13 @@
.labels-container
-# Only show it in the first page
- hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1')
- .prioritized-labels.gl-rounded-base.gl-border.gl-bg-gray-10.gl-mt-4{ class: [('hide' if hide), ('is-not-draggable' unless can_admin_label)] }
- .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-top-base.gl-border-b
- %h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24
- = _('Prioritized labels')
- .gl-font-sm.gl-font-weight-semibold.gl-text-gray-500
- = _('Drag to reorder prioritized labels and change their relative priority.')
+ .prioritized-labels.gl-new-card{ class: [('hide' if hide), ('is-not-draggable' unless can_admin_label)] }
+ .gl-new-card-header
+ .gl-new-card-title-wrapper.gl-flex-direction-column
+ %h3.gl-new-card-title
+ = _('Prioritized labels')
+ .gl-new-card-description
+ = _('Drag to reorder prioritized labels and change their relative priority.')
.js-prioritized-labels.gl-px-3.gl-rounded-base.manage-labels-list{ data: { url: set_priorities_project_labels_path(@project), sortable: can_admin_label } }
#js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" }
= render 'shared/empty_states/priority_labels'
@@ -32,12 +33,14 @@
= _('No prioritized labels with such name or description')
- if @labels.any?
- .other-labels.gl-rounded-base.gl-border.gl-bg-gray-10.gl-mt-4
- .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-top-base.gl-border-b
- %h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24{ class: ('hide' if hide) }= _('Other labels')
- .js-other-labels.gl-px-3.gl-rounded-base.manage-labels-list
- = render partial: 'shared/label', collection: @labels, as: :label, locals: { subject: @project }
- = paginate @labels, theme: 'gitlab'
+ .other-labels.gl-new-card
+ .gl-new-card-header
+ .gl-new-card-title-wrapper
+ %h3.gl-new-card-title{ class: ('hide' if hide) }= _('Other labels')
+ .gl-new-card-body
+ .js-other-labels.manage-labels-list.gl-new-card-content
+ = render partial: 'shared/label', collection: @labels, as: :label, locals: { subject: @project }
+ = paginate @labels, theme: 'gitlab'
- elsif search.present?
.other-labels
diff --git a/app/views/projects/mattermosts/_no_teams.html.haml b/app/views/projects/mattermosts/_no_teams.html.haml
index 5886c0565b1..c53e805fae1 100644
--- a/app/views/projects/mattermosts/_no_teams.html.haml
+++ b/app/views/projects/mattermosts/_no_teams.html.haml
@@ -9,4 +9,4 @@
and try again.
%hr
.clearfix
- = link_to 'Go back', edit_project_settings_integration_path(@project, @integration), class: 'gl-button btn btn-lg float-right'
+ = link_button_to 'Go back', edit_project_settings_integration_path(@project, @integration), class: 'float-right'
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 a3536ead240..ab841d4f1b2 100644
--- a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
+++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
@@ -1,6 +1,7 @@
.js-mr-more-dropdown{ data: {
merge_request: @merge_request.to_json,
project_path: @project.full_path,
+ url: merge_request_url(@merge_request),
edit_url: edit_project_merge_request_path(@project, @merge_request),
is_current_user: issuable_author_is_current_user(@merge_request),
is_logged_in: current_user,
@@ -11,5 +12,4 @@
clipboard_text: @merge_request.to_reference(full: true),
report_abuse_path: add_category_abuse_reports_path,
reported_user_id: @merge_request.author.id,
- reported_from_url: merge_request_url(@merge_request),
} }
diff --git a/app/views/projects/merge_requests/_form.html.haml b/app/views/projects/merge_requests/_form.html.haml
index 5f1c72156eb..6d2e2cfcc54 100644
--- a/app/views/projects/merge_requests/_form.html.haml
+++ b/app/views/projects/merge_requests/_form.html.haml
@@ -1,3 +1,4 @@
= gitlab_ui_form_for [@project, @merge_request],
html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f|
+ = render 'source_and_target', mr: @merge_request
= render 'shared/issuable/form', f: f, issuable: @merge_request, presenter: @mr_presenter
diff --git a/app/views/projects/merge_requests/_mr_box.html.haml b/app/views/projects/merge_requests/_mr_box.html.haml
index 6f662b81dd7..1774401ed78 100644
--- a/app/views/projects/merge_requests/_mr_box.html.haml
+++ b/app/views/projects/merge_requests/_mr_box.html.haml
@@ -1,3 +1,3 @@
-.detail-page-description.gl-pt-2.gl-pb-4.gl-display-flex.gl-align-items-center.gl-flex-wrap{ class: "#{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" }
+.detail-page-description.gl-pt-2.gl-pb-4.gl-display-flex.gl-align-items-baseline.gl-flex-wrap{ class: "#{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" }
= render 'shared/issuable/status_box', issuable: @merge_request
= merge_request_header(@project, @merge_request)
diff --git a/app/views/projects/merge_requests/_source_and_target.html.haml b/app/views/projects/merge_requests/_source_and_target.html.haml
new file mode 100644
index 00000000000..68cd4fe9372
--- /dev/null
+++ b/app/views/projects/merge_requests/_source_and_target.html.haml
@@ -0,0 +1,10 @@
+%span{
+ id: "js-merge-request-metadata",
+ class: ["js-merge-request-metadata", "gl-display-none"],
+ data: {
+ "source-project-id": mr.source_project_id,
+ "source-branch": mr.source_branch,
+ "target-project-id": mr.target_project_id,
+ "target-branch": mr.target_branch
+ }
+}
diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml
index 576fed58609..606d4e06d33 100644
--- a/app/views/projects/merge_requests/_widget.html.haml
+++ b/app/views/projects/merge_requests/_widget.html.haml
@@ -23,4 +23,9 @@
window.gl.mrWidgetData.user_preferences_gitpod_path = '#{profile_preferences_path(anchor: 'user_gitpod_enabled')}';
window.gl.mrWidgetData.user_profile_enable_gitpod_path = '#{profile_path(user: { gitpod_enabled: true })}';
-#js-vue-mr-widget.mr-widget
+%h2#merge-request-widgets-heading.gl-sr-only
+ = _("Merge request reports")
+#js-vue-mr-widget.mr-widget{
+ role: 'region',
+ 'aria-labelledby': 'merge-request-widgets-heading'
+}
diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml
index bec7cb3fd34..a7151421acb 100644
--- a/app/views/projects/merge_requests/creations/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml
@@ -1,6 +1,7 @@
%h1.page-title.gl-font-size-h-display
= _('New merge request')
= gitlab_ui_form_for [@project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f|
+ = render "projects/merge_requests/source_and_target", mr: @merge_request
= render 'shared/issuable/form', f: f, issuable: @merge_request, commits: @commits, presenter: @mr_presenter
= f.hidden_field :source_project_id
= f.hidden_field :source_branch
diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml
index be6f9ac83dc..a592062a17d 100644
--- a/app/views/projects/milestones/_form.html.haml
+++ b/app/views/projects/milestones/_form.html.haml
@@ -12,13 +12,16 @@
= render 'shared/milestones/form_dates', f: f
.form-group
= f.label :description, _('Description')
- = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project) } do
- = render 'shared/zen', f: f, attr: :description,
- classes: 'note-textarea',
- qa_selector: 'milestone_description_field',
- supports_autocomplete: true,
- placeholder: _('Write milestone description...')
- = render 'shared/notes/hints'
+ - @gfm_form = true
+ .js-markdown-editor{ data: { render_markdown_path: preview_markdown_path(@project),
+ markdown_docs_path: help_page_path('user/markdown'),
+ qa_selector: 'milestone_description_field',
+ form_field_placeholder: _('Write milestone description...'),
+ supports_quick_actions: 'false',
+ enable_autocomplete: 'true',
+ autofocus: 'false',
+ form_field_classes: 'note-textarea js-gfm-input markdown-area' } }
+ = f.hidden_field :description
.clearfix
.error-alert
@@ -26,7 +29,7 @@
- if @milestone.new_record?
= f.submit _('Create milestone'), data: { qa_selector: 'create_milestone_button' }, class: 'gl-mr-2', pajamas_button: true
- = link_to _('Cancel'), project_milestones_path(@project), class: 'gl-button btn btn-default btn-cancel'
+ = link_button_to _('Cancel'), project_milestones_path(@project)
- else
= f.submit _('Save changes'), class: 'gl-mr-2', pajamas_button: true
- = link_to _('Cancel'), project_milestone_path(@project, @milestone), class: 'gl-button btn btn-default btn-cancel'
+ = link_button_to _('Cancel'), project_milestone_path(@project, @milestone)
diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml
index 326a7c4027f..a7a21ef0440 100644
--- a/app/views/projects/milestones/index.html.haml
+++ b/app/views/projects/milestones/index.html.haml
@@ -9,14 +9,14 @@
= render 'shared/milestones/search_form'
= render 'shared/milestones_sort_dropdown'
- if can?(current_user, :admin_milestone, @project)
- = link_to new_project_milestone_path(@project), class: 'gl-button btn btn-confirm gl-ml-3', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone') do
+ = link_button_to new_project_milestone_path(@project), class: 'gl-ml-3', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone'), variant: :confirm do
= _('New milestone')
- if @milestones.blank?
= render 'shared/empty_states/milestones_tab' do
- if can?(current_user, :admin_milestone, @project)
.text-center
- = link_to new_project_milestone_path(@project), class: 'gl-button btn btn-confirm', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone') do
+ = link_button_to new_project_milestone_path(@project), data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone'), variant: :confirm do
= _('New milestone')
- else
@@ -32,5 +32,5 @@
= render 'shared/empty_states/milestones' do
- if can?(current_user, :admin_milestone, @project)
.text-center
- = link_to new_project_milestone_path(@project), class: 'gl-button btn btn-confirm', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone') do
+ = link_button_to new_project_milestone_path(@project), data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone'), variant: :confirm do
= _('New milestone')
diff --git a/app/views/projects/ml/models/index.html.haml b/app/views/projects/ml/models/index.html.haml
new file mode 100644
index 00000000000..2caba2ae9be
--- /dev/null
+++ b/app/views/projects/ml/models/index.html.haml
@@ -0,0 +1,5 @@
+- breadcrumb_title s_('ModelRegistry|Model registry')
+- page_title s_('ModelRegistry|Model registry')
+- presenter = ::Ml::ModelsIndexPresenter.new(@models)
+
+#js-index-ml-models{ data: { view_model: presenter.present } }
diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml
index e3f46d601a3..3abec75b971 100644
--- a/app/views/projects/no_repo.html.haml
+++ b/app/views/projects/no_repo.html.haml
@@ -13,14 +13,14 @@
%hr
.no-repo-actions
- = link_to project_repository_path(@project), method: :post, class: 'btn gl-button btn-confirm' do
- #{ _('Create empty repository') }
+ = link_button_to project_repository_path(@project), method: :post, variant: :confirm do
+ = _('Create empty repository')
%strong.gl-ml-3.gl-mr-3 or
- = link_to new_project_import_path(@project), class: 'btn gl-button btn-default' do
- #{ _('Import repository') }
+ = link_button_to new_project_import_path(@project) do
+ = _('Import repository')
- if can? current_user, :remove_project, @project
.prepend-top-20
- = link_to _('Delete project'), project_path(@project), data: { confirm: remove_project_message(@project), confirm_btn_variant: 'danger' }, aria: { label: _('Delete project') }, method: :delete, class: "btn gl-button btn-danger float-right"
+ = link_button_to _('Delete project'), project_path(@project), data: { confirm: remove_project_message(@project), confirm_btn_variant: 'danger' }, aria: { label: _('Delete project') }, method: :delete, class: 'float-right', variant: :danger
diff --git a/app/views/projects/packages/packages/index.html.haml b/app/views/projects/packages/packages/index.html.haml
index 48aaf0884c8..5397828d48e 100644
--- a/app/views/projects/packages/packages/index.html.haml
+++ b/app/views/projects/packages/packages/index.html.haml
@@ -10,4 +10,5 @@
npm_instance_url: package_registry_instance_url(:npm),
project_list_url: project_packages_path(@project),
settings_path: show_package_registry_settings(@project) ? project_settings_packages_and_registries_path(@project) : '',
+ can_delete_packages: can_delete_packages?(@project).to_s,
group_list_url: '' } }
diff --git a/app/views/projects/pages/_access.html.haml b/app/views/projects/pages/_access.html.haml
index 50e48187be3..6eab31075d4 100644
--- a/app/views/projects/pages/_access.html.haml
+++ b/app/views/projects/pages/_access.html.haml
@@ -1,5 +1,5 @@
- if @project.pages_deployed?
- - pages_url = @project.pages_url(with_unique_domain: true)
+ - pages_url = build_pages_url(@project, with_unique_domain: true)
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5', data: { qa_selector: 'access_page_container' } }, footer_options: { class: 'gl-alert-warning' }) do |c|
- c.with_header do
diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml
index 57371aa49f6..38e15d02a39 100644
--- a/app/views/projects/pages/_list.html.haml
+++ b/app/views/projects/pages/_list.html.haml
@@ -26,8 +26,8 @@
- if domain.expired?
= gl_badge_tag s_('GitLabPages|Expired'), variant: :danger
%div
- = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn gl-button btn-sm btn-grouped btn-confirm btn-inverted"
- = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?'), 'confirm-btn-variant': 'danger'}, "aria-label": s_("GitLabPages|Remove domain"), method: :delete, class: "btn gl-button btn-danger btn-sm btn-grouped"
+ = link_button_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: 'btn-grouped', variant: :confirm, category: :secondary, size: :small
+ = link_button_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?'), 'confirm-btn-variant': 'danger'}, "aria-label": s_("GitLabPages|Remove domain"), method: :delete, class: 'btn-grouped', variant: :danger, size: :small
- if domain.needs_verification?
%li.list-group-item.bs-callout-warning
- details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe
diff --git a/app/views/projects/pages/new.html.haml b/app/views/projects/pages/new.html.haml
index b9d2af9cf19..89f8f62ea83 100644
--- a/app/views/projects/pages/new.html.haml
+++ b/app/views/projects/pages/new.html.haml
@@ -1,9 +1,5 @@
-- 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
+- @breadcrumb_link = project_pages_path(@project)
+- page_title s_('GitLabPages|Pages')
- if Feature.enabled?(:use_pipeline_wizard_for_pages, @project.group)
#js-pages{ data: @pipeline_wizard_data }
diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml
index 01477967394..698ce404be8 100644
--- a/app/views/projects/pages/show.html.haml
+++ b/app/views/projects/pages/show.html.haml
@@ -1,4 +1,4 @@
-- page_title _('Pages')
+- page_title s_('GitLabPages|Pages')
- unless @project.pages_deployed?
= render 'waiting'
@@ -11,7 +11,7 @@
= render 'pages_settings'
%hr.clearfix
- = render 'ssl_limitations_warning' if @project.pages_subdomain.include?(".")
+ = render 'ssl_limitations_warning' if pages_subdomain(@project).include?(".")
= render 'access'
- if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https
= render 'list'
diff --git a/app/views/projects/pages_domains/_dns.html.haml b/app/views/projects/pages_domains/_dns.html.haml
index 3e6a92d8bc0..0edce28bb9d 100644
--- a/app/views/projects/pages_domains/_dns.html.haml
+++ b/app/views/projects/pages_domains/_dns.html.haml
@@ -1,5 +1,5 @@
- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
-- dns_record = "#{domain_presenter.domain} ALIAS #{domain_presenter.project.pages_subdomain}.#{Settings.pages.host}."
+- dns_record = "#{domain_presenter.domain} ALIAS #{pages_subdomain(domain_presenter.project)}.#{Settings.pages.host}."
.form-group.border-section
.row
@@ -21,11 +21,11 @@
.gl-mb-3
- text, status = domain_presenter.unverified? ? [_('Unverified'), :danger] : [_('Verified'), :success]
= gl_badge_tag text, variant: status
- = link_to sprite_icon("redo"), verify_project_pages_domain_path(@project, domain_presenter), method: :post, class: "gl-ml-2 gl-button btn btn-sm btn-default has-tooltip", title: _("Retry verification")
+ = link_button_to sprite_icon("redo"), verify_project_pages_domain_path(@project, domain_presenter), method: :post, class: 'gl-ml-2 has-tooltip', title: _("Retry verification"), size: :small
.input-group
= text_field_tag :domain_verification, domain_presenter.verification_record, class: "monospace js-select-on-focus form-control", readonly: true
.input-group-append
= clipboard_button(target: '#domain_verification', class: 'btn-default d-none d-sm-block')
%p.form-text.text-muted
- link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: '4-verify-the-domains-ownership'))
- = _("To %{link_to_help} of your domain, add the above key to a TXT record within your DNS configuration.").html_safe % { link_to_help: link_to_help }
+ = _("To %{link_to_help} of your domain, add the above key to a TXT record within your DNS configuration within seven days.").html_safe % { link_to_help: link_to_help }
diff --git a/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml b/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml
index d6c213571f2..68b6884c4f5 100644
--- a/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml
+++ b/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml
@@ -9,7 +9,7 @@
= sprite_icon('warning-solid', css_class: ' mr-2 gl-text-orange-600')
= _("Something went wrong while obtaining the Let's Encrypt certificate.")
.row.mx-0.mt-3
- = link_to s_('GitLabPagesDomains|Retry'), retry_auto_ssl_project_pages_domain_path(@project, domain_presenter), class: "gl-button btn btn-default btn-sm btn-grouped", method: :post
+ = link_button_to s_('GitLabPagesDomains|Retry'), retry_auto_ssl_project_pages_domain_path(@project, domain_presenter), class: 'btn-grouped', method: :post, size: :small
- elsif !domain_presenter.certificate_gitlab_provided?
.form-group.border-section.js-shown-if-auto-ssl{ class: ("d-none" unless auto_ssl_available_and_enabled) }
.row
diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml
index c88255e23f9..c58209f8806 100644
--- a/app/views/projects/pages_domains/new.html.haml
+++ b/app/views/projects/pages_domains/new.html.haml
@@ -8,4 +8,4 @@
= render 'form', { f: f }
.form-actions.gl-display-flex
= f.submit _('Create New Domain'), class: 'gl-mr-3', pajamas_button: true
- = link_to _('Cancel'), project_pages_path(@project), class: 'gl-button btn btn-default btn-cancel'
+ = link_button_to _('Cancel'), project_pages_path(@project)
diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml
index b8de364babc..d34650d3f5a 100644
--- a/app/views/projects/pages_domains/show.html.haml
+++ b/app/views/projects/pages_domains/show.html.haml
@@ -10,4 +10,4 @@
= render 'form', { f: f }
.form-actions.gl-display-flex
= f.submit _('Save Changes'), class: 'gl-mr-3', pajamas_button: true
- = link_to _('Cancel'), project_pages_path(@project), class: 'gl-button btn btn-default btn-inverse'
+ = link_button_to _('Cancel'), project_pages_path(@project)
diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml
index 235b89b8c5b..df85963218d 100644
--- a/app/views/projects/pipeline_schedules/_form.html.haml
+++ b/app/views/projects/pipeline_schedules/_form.html.haml
@@ -40,4 +40,4 @@
= f.gitlab_ui_checkbox_component :active, _('Active'), checkbox_options: { value: @schedule.active, required: false }
.footer-block
= f.submit _('Save pipeline schedule'), pajamas_button: true
- = link_to _('Cancel'), pipeline_schedules_path(@project), class: 'btn gl-button btn-default btn-cancel'
+ = link_button_to _('Cancel'), pipeline_schedules_path(@project)
diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
index 37b2b3ecfde..a050808f13c 100644
--- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
+++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
@@ -35,14 +35,11 @@
%td{ role: 'cell', data: { label: _('Actions') } }
.float-right.btn-group
- if can?(current_user, :play_pipeline_schedule, pipeline_schedule)
- = link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: _('Play'), class: 'btn gl-button btn-default btn-icon' do
- = sprite_icon('play')
+ = link_button_to nil, play_pipeline_schedule_path(pipeline_schedule), method: :post, title: _('Play'), icon: 'play'
- if can?(current_user, :admin_pipeline_schedule, pipeline_schedule) && pipeline_schedule.owner != current_user
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-take-ownership-button has-tooltip', title: s_('PipelineSchedule|Take ownership to edit'), data: { url: take_ownership_pipeline_schedule_path(pipeline_schedule) } }) do
= s_('PipelineSchedules|Take ownership')
- if can?(current_user, :update_pipeline_schedule, pipeline_schedule)
- = link_to edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), class: 'btn gl-button btn-default btn-icon' do
- = sprite_icon('pencil')
+ = link_button_to nil, edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), icon: 'pencil'
- if can?(current_user, :admin_pipeline_schedule, pipeline_schedule)
- = link_to pipeline_schedule_path(pipeline_schedule), title: _('Delete'), method: :delete, class: 'btn gl-button btn-danger btn-icon', aria: { label: _('Delete pipeline schedule') }, data: { confirm: _("Are you sure you want to delete this pipeline schedule?"), confirm_btn_variant: 'danger' } do
- = sprite_icon('remove')
+ = link_button_to nil, pipeline_schedule_path(pipeline_schedule), title: _('Delete'), method: :delete, aria: { label: _('Delete pipeline schedule') }, data: { confirm: _("Are you sure you want to delete this pipeline schedule?"), confirm_btn_variant: 'danger' }, variant: :danger, icon: 'remove'
diff --git a/app/views/projects/pipeline_schedules/edit.html.haml b/app/views/projects/pipeline_schedules/edit.html.haml
index 3f843ce6aec..4e1ae53a101 100644
--- a/app/views/projects/pipeline_schedules/edit.html.haml
+++ b/app/views/projects/pipeline_schedules/edit.html.haml
@@ -5,9 +5,8 @@
%h1.page-title.gl-font-size-h-display
= _("Edit Pipeline Schedule")
-%hr
- if Feature.enabled?(:pipeline_schedules_vue, @project)
- #pipeline-schedules-form-edit{ data: { full_path: @project.full_path } }
+ #pipeline-schedules-form-edit{ data: js_pipeline_schedules_form_data(@project, @schedule) }
- else
= render "form"
diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml
index ab86d505f0f..88a60b1fb06 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, pipelines_path: project_pipelines_path(@project) } }
+ #pipeline-schedules-app{ data: { full_path: @project.full_path, pipelines_path: project_pipelines_path(@project), new_schedule_path: new_project_pipeline_schedule_path(@project) } }
- else
.top-area
- schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) }
@@ -14,8 +14,8 @@
- if can?(current_user, :create_pipeline_schedule, @project)
.nav-controls
- = link_to new_project_pipeline_schedule_path(@project), class: 'btn gl-button btn-confirm' do
- %span= _('New schedule')
+ = link_button_to new_project_pipeline_schedule_path(@project), variant: :confirm do
+ = _('New schedule')
- if @schedules.present?
%ul.content-list
diff --git a/app/views/projects/pipeline_schedules/new.html.haml b/app/views/projects/pipeline_schedules/new.html.haml
index 2d4ed5a9872..ef99a79b06f 100644
--- a/app/views/projects/pipeline_schedules/new.html.haml
+++ b/app/views/projects/pipeline_schedules/new.html.haml
@@ -9,6 +9,6 @@
= _("Schedule a new pipeline")
- if Feature.enabled?(:pipeline_schedules_vue, @project)
- #pipeline-schedules-form-new{ data: { full_path: @project.full_path, cron: @schedule.cron, daily_limit: @schedule.daily_limit, timezone_data: timezone_data.to_json, cron_timezone: @schedule.cron_timezone, project_id: @project.id, default_branch: @project.default_branch, settings_link: project_settings_ci_cd_path(@project), } }
+ #pipeline-schedules-form-new{ data: js_pipeline_schedules_form_data(@project, @schedule) }
- else
= render "form"
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
deleted file mode 100644
index 753bb77e755..00000000000
--- a/app/views/projects/pipelines/_info.html.haml
+++ /dev/null
@@ -1,75 +0,0 @@
-- if @pipeline.name
- .gl-border-t.gl-p-5.gl-px-0
- %h3.gl-m-0.gl-text-body
- = @pipeline.name
-- else
- .commit-box
- %h3.commit-title
- = markdown(commit.title, pipeline: :single_line)
- - if commit.description.present?
- %pre.commit-description<
- = preserve(markdown(commit.description, pipeline: :single_line))
-
-.info-well
- .well-segment.pipeline-info{ class: "gl-align-items-baseline! gl-flex-direction-column" }
- %div
- .icon-container
- = sprite_icon('clock', css_class: 'gl-top-0!')
- = n_('%d job', '%d jobs', @pipeline.total_size) % @pipeline.total_size
- = @pipeline.ref_text_legacy
- - if @pipeline.finished_at
- - duration = time_interval_in_words(@pipeline.duration)
- - queued_duration = time_interval_in_words(@pipeline.queued_duration)
- %span.gl-pl-7{ 'data-testid': 'pipeline-stats-text' }
- = render_if_exists 'projects/pipelines/pipeline_stats_text', duration: duration, pipeline: @pipeline, queued_duration: queued_duration
-
- - if has_pipeline_badges?(@pipeline)
- .well-segment
- .icon-container
- = sprite_icon('flag', css_class: 'gl-top-0!')
- - if @pipeline.schedule?
- = gl_badge_tag _('Scheduled'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-scheduled', title: _('This pipeline was triggered by a schedule.') }
- - if @pipeline.child?
- - text = sprintf(s_('Pipelines|Child pipeline (%{link_start}parent%{link_end})'), { link_start: "<a href='#{pipeline_path(@pipeline.triggered_by_pipeline)}' class='text-underline'>", link_end: "</a>"}).html_safe
- = gl_badge_tag text, { variant: :info, size: :sm }, { class: 'js-pipeline-child has-tooltip', title: s_("Pipelines|This is a child pipeline within the parent pipeline") }
- - if @pipeline.latest?
- = gl_badge_tag s_('Pipelines|latest'), { variant: :success, size: :sm }, { class: 'js-pipeline-url-latest has-tooltip', title: _("Latest pipeline for the most recent commit on this branch") }
- - if @pipeline.merge_train_pipeline?
- = gl_badge_tag s_('Pipelines|merge train'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-train has-tooltip', title: s_("Pipelines|This pipeline ran on the contents of this merge request combined with the contents of all other merge requests queued for merging into the target branch.") }
- - if @pipeline.has_yaml_errors?
- = gl_badge_tag s_('Pipelines|yaml invalid'), { variant: :danger, size: :sm }, { class: 'js-pipeline-url-yaml has-tooltip', title: @pipeline.yaml_errors }
- - if @pipeline.failure_reason?
- = gl_badge_tag s_('Pipelines|error'), { variant: :danger, size: :sm }, { class: 'js-pipeline-url-failure has-tooltip', title: @pipeline.failure_reason }
- - if @pipeline.auto_devops_source?
- - popover_title_text = html_escape(_('This pipeline makes use of a predefined CI/CD configuration enabled by %{b_open}Auto DevOps.%{b_close}')) % { b_open: '<b>'.html_safe, b_close: '</b>'.html_safe }
- - popover_content_url = help_page_path('topics/autodevops/index.md')
- - popover_content_text = _('Learn more about Auto DevOps')
- = gl_badge_tag s_('Pipelines|Auto DevOps'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-autodevops', href: "#", tabindex: "0", role: "button", data: { container: 'body', toggle: 'popover', placement: 'top', html: 'true', triggers: 'focus', title: "<div class='gl-font-weight-normal gl-line-height-normal'>#{popover_title_text}</div>", content: "<a href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>" } }
- - if @pipeline.detached_merge_request_pipeline?
- = gl_badge_tag s_('Pipelines|merge request'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-mergerequest has-tooltip', data: { qa_selector: 'merge_request_badge_tag' }, title: s_("Pipelines|This pipeline ran on the contents of this merge request's source branch, not the target branch.") }
- - if @pipeline.stuck?
- = gl_badge_tag s_('Pipelines|stuck'), { variant: :warning, size: :sm }, { class: 'js-pipeline-url-stuck has-tooltip' }
-
- .well-segment{ 'data-testid': 'commit-row' }
- .icon-container.commit-icon
- = sprite_icon('commit', css_class: 'gl-top-0!')
- - 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"
- - else
- = link_to commit.short_id, project_commit_path(@project, @pipeline.sha), class: "commit-sha"
- = clipboard_button(text: @pipeline.sha, title: _("Copy commit SHA"))
-
- .well-segment.related-merge-request-info
- .icon-container
- = sprite_icon("git-merge", css_class: 'gl-top-0!')
- %span.related-merge-requests
- %span.js-truncated-mr-list
- = @pipeline.all_related_merge_request_text(limit: 1)
- - if @pipeline.has_many_merge_requests?
- = link_to("#", class: "js-toggle-mr-list") do
- %span.text-expander
- = sprite_icon('ellipsis_h', size: 12)
- %span.js-full-mr-list.hide
- = @pipeline.all_related_merge_request_text
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index 46e1cd07a17..bdf09e5356f 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -9,16 +9,10 @@
- add_page_startup_graphql_call('pipelines/get_pipeline_details', { projectPath: @project.full_path, iid: @pipeline.iid })
.js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } }
- - if Feature.enabled?(:pipeline_details_header_vue, @project)
- #js-pipeline-details-header-vue{ data: js_pipeline_details_header_data(@project, @pipeline) }
- - else
- #js-pipeline-header-vue.pipeline-header-container{ data: { full_path: @project.full_path, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline), pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id, pipelines_path: project_pipelines_path(@project) } }
+ #js-pipeline-details-header-vue{ data: js_pipeline_details_header_data(@project, @pipeline) }
= render_if_exists 'projects/pipelines/cc_validation_required_alert', pipeline: @pipeline
- - if @pipeline.commit.present? && !Feature.enabled?(:pipeline_details_header_vue, @project)
- = render "projects/pipelines/info", commit: @pipeline.commit
-
- if pipeline_has_errors
= render Pajamas::AlertComponent.new(title: s_('Pipelines|Unable to create pipeline'),
variant: :danger,
diff --git a/app/views/projects/project_templates/_template.html.haml b/app/views/projects/project_templates/_template.html.haml
index 9dde86f77b4..93c53fc99fc 100644
--- a/app/views/projects/project_templates/_template.html.haml
+++ b/app/views/projects/project_templates/_template.html.haml
@@ -8,7 +8,7 @@
.text-muted
= template.description
.controls.d-flex.align-items-center
- %a.btn.gl-button.btn-default.gl-mr-3{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "template_preview", track_property: template.name, track_action: "click_button", track_value: "" } }
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'gl-mr-3', data: { track_label: "template_preview", track_property: template.name, track_action: "click_button", track_value: "" }, rel: 'noopener noreferrer' }, href: template.preview, target: '_blank') do
= _("Preview")
%label.btn.gl-button.btn-confirm.template-button.choose-template.gl-mb-0{ for: template.name,
'data-testid': "use_template_#{template.name}" }
diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml
index 11e09d843e0..a016ccf8656 100644
--- a/app/views/projects/protected_tags/shared/_index.html.haml
+++ b/app/views/projects/protected_tags/shared/_index.html.haml
@@ -8,7 +8,7 @@
= expanded ? _('Collapse') : _('Expand')
%p
= s_("ProtectedTag|Limit access to creating and updating tags.")
- = link_to s_("ProtectedTag|What are protected tags?"), help_page_path("user/project/protected_tags")
+ = link_to s_("ProtectedTag|What are protected tags?"), help_page_path("user/project/protected_tags")
.settings-content
%p
= s_("ProtectedTag|By default, protected tags restrict who can modify the tag.")
diff --git a/app/views/projects/protected_tags/shared/_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_protected_tag.html.haml
index ed5b5b17942..4fe1c8bd3cb 100644
--- a/app/views/projects/protected_tags/shared/_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/shared/_protected_tag.html.haml
@@ -19,4 +19,4 @@
- if can? current_user, :admin_project, @project
%td
- = link_to 'Unprotect', [@project, protected_tag, { update_section: 'js-protected-tags-settings' }], aria: { label: s_('ProtectedTags|Unprotect tag') }, data: { confirm: 'Tag will be writable for developers. Are you sure?', confirm_btn_variant: 'danger' }, method: :delete, class: 'gl-button btn btn-danger-secondary'
+ = link_button_to 'Unprotect', [@project, protected_tag, { update_section: 'js-protected-tags-settings' }], aria: { label: s_('ProtectedTags|Unprotect tag') }, data: { confirm: 'Tag will be writable for developers. Are you sure?', confirm_btn_variant: 'danger' }, method: :delete, variant: :danger, category: :secondary
diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml
index d71bcd12e64..32a2e36c779 100644
--- a/app/views/projects/runners/_group_runners.html.haml
+++ b/app/views/projects/runners/_group_runners.html.haml
@@ -13,10 +13,10 @@
%br
%br
- if @project.group_runners_enabled?
- = link_to toggle_group_runners_project_runners_path(@project), class: 'btn gl-button btn-default', method: :post do
+ = link_button_to toggle_group_runners_project_runners_path(@project), method: :post do
= _('Disable group runners')
- else
- = link_to toggle_group_runners_project_runners_path(@project), class: 'btn gl-button btn-confirm-secondary', method: :post do
+ = link_button_to toggle_group_runners_project_runners_path(@project), method: :post, variant: :confirm, category: :secondary do
= _('Enable group runners')
&nbsp;
= _('for this project')
diff --git a/app/views/projects/runners/_project_runners.html.haml b/app/views/projects/runners/_project_runners.html.haml
index af8f39ce0ad..0f2f0c3f21c 100644
--- a/app/views/projects/runners/_project_runners.html.haml
+++ b/app/views/projects/runners/_project_runners.html.haml
@@ -3,26 +3,14 @@
.bs-callout.help-callout
%p= s_('Runners|These runners are assigned to this project.')
- - if Feature.enabled?(:create_runner_workflow_for_namespace, @project.namespace)
- - if can?(current_user, :create_runner, @project)
- = render Pajamas::ButtonComponent.new(href: new_project_runner_path(@project), variant: :confirm) do
- = s_('Runners|New project runner')
- .gl-display-inline
- #js-project-runner-registration-dropdown{ data: { registration_token: @project.runners_token, project_id: @project.id } }
- - else
- = _('Please contact an admin to create runners.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer'
+ - if can?(current_user, :create_runner, @project)
+ = render Pajamas::ButtonComponent.new(href: new_project_runner_path(@project), variant: :confirm) do
+ = s_('Runners|New project runner')
+ .gl-display-inline
+ #js-project-runner-registration-dropdown{ data: { registration_token: @project.runners_token, project_id: @project.id } }
- else
- - if can?(current_user, :register_project_runners, @project)
- = render partial: 'ci/runner/how_to_setup_runner',
- locals: { registration_token: @project.runners_token,
- type: _('project'),
- reset_token_url: reset_registration_token_namespace_project_settings_ci_cd_path,
- project_path: @project.path_with_namespace,
- group_path: '' }
- - else
- = _('Please contact an admin to register runners.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer'
+ = _('Please contact an admin to create runners.')
+ = link_to _('Learn more.'), help_page_path('administration/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer'
%hr
diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml
index e517b37aae9..12432cd3484 100644
--- a/app/views/projects/runners/_runner.html.haml
+++ b/app/views/projects/runners/_runner.html.haml
@@ -13,19 +13,16 @@
.gl-ml-2
.btn-group.btn-group-sm
- if @project_runners.include?(runner)
- = link_to edit_project_runner_path(@project, runner), class: 'btn gl-button btn-icon', title: _('Edit'), aria: { label: _('Edit') }, data: { testid: 'edit-runner-link', toggle: 'tooltip', placement: 'top', container: 'body' } do
- = sprite_icon('pencil')
+ = link_button_to nil, edit_project_runner_path(@project, runner), title: _('Edit'), aria: { label: _('Edit') }, data: { testid: 'edit-runner-link', toggle: 'tooltip', placement: 'top', container: 'body' }, icon: 'pencil'
- if runner.active?
- = link_to pause_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-icon', title: s_('Runners|Pause from accepting jobs'), aria: { label: _('Pause') }, data: { toggle: 'tooltip', container: 'body', confirm: _("Are you sure?") } do
- = sprite_icon('pause')
+ = link_button_to nil, pause_project_runner_path(@project, runner), method: :post, title: s_('Runners|Pause from accepting jobs'), aria: { label: _('Pause') }, data: { toggle: 'tooltip', container: 'body', confirm: _("Are you sure?") }, icon: 'pause'
- else
- = link_to resume_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-icon', title: s_('Runners|Resume accepting jobs'), aria: { label: _('Resume') }, data: { toggle: 'tooltip', container: 'body' } do
- = sprite_icon('play')
+ = link_button_to nil, resume_project_runner_path(@project, runner), method: :post, title: s_('Runners|Resume accepting jobs'), aria: { label: _('Resume') }, data: { toggle: 'tooltip', container: 'body' }, icon: 'play'
- if runner.belongs_to_one_project?
- = link_to _('Remove runner'), project_runner_path(@project, runner), aria: { label: _('Remove') }, data: { confirm: _("Are you sure?"), 'confirm-btn-variant': 'danger' }, method: :delete, class: 'btn gl-button btn-danger'
+ = link_button_to _('Remove runner'), project_runner_path(@project, runner), aria: { label: _('Remove') }, data: { confirm: _("Are you sure?"), 'confirm-btn-variant': 'danger' }, method: :delete, variant: :danger
- else
- runner_project = @project.runner_projects.find_by(runner_id: runner) # rubocop: disable CodeReuse/ActiveRecord
- = link_to _('Disable for this project'), project_runner_project_path(@project, runner_project), aria: { label: _('Disable') }, data: { confirm: _("Are you sure?"), 'confirm-btn-variant': 'danger' }, method: :delete, class: 'btn gl-button btn-danger'
+ = link_button_to _('Disable for this project'), project_runner_project_path(@project, runner_project), aria: { label: _('Disable') }, data: { confirm: _("Are you sure?"), 'confirm-btn-variant': 'danger' }, method: :delete, variant: :danger
- elsif runner.project_type?
= form_for [@project, @project.runner_projects.new] do |f|
= f.hidden_field :runner_id, value: runner.id
diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml
index 70e14eadaf9..05685c26ac5 100644
--- a/app/views/projects/settings/_archive.html.haml
+++ b/app/views/projects/settings/_archive.html.haml
@@ -9,14 +9,10 @@
- if @project.archived?
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'unarchive-a-project') }
%p= _("Unarchiving the project restores its members' ability to make commits, and create issues, comments, and other entities. %{strong_start}After you unarchive the project, it displays in the search and on the dashboard.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe }
- = link_to _('Unarchive project'), unarchive_project_path(@project),
- aria: { label: _('Unarchive project') },
- data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' },
- method: :post, class: "gl-button btn btn-confirm"
+ = render Pajamas::ButtonComponent.new(method: :post, href: unarchive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Unarchive project') }, data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' } }) do
+ = _('Unarchive project')
- else
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'archive-a-project') }
%p= _("Archiving the project makes it entirely read-only. It is hidden from the dashboard and doesn't display in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe }
- = link_to _('Archive project'), archive_project_path(@project),
- aria: { label: _('Archive project') },
- data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link', 'confirm-btn-variant': 'confirm' },
- method: :post, class: "gl-button btn btn-confirm"
+ = render Pajamas::ButtonComponent.new(method: :post, href: archive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Archive project') }, data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link', 'confirm-btn-variant': 'confirm' } }) do
+ = _('Archive project')
diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml
index 847f9ad3e2a..f5c275827fc 100644
--- a/app/views/projects/settings/_general.html.haml
+++ b/app/views/projects/settings/_general.html.haml
@@ -35,6 +35,6 @@
= render 'shared/choose_avatar_button', f: f
- if @project.avatar?
%hr
- = link_to _('Remove avatar'), project_avatar_path(@project), aria: { label: _('Remove avatar') }, data: { confirm: _('Avatar will be removed. Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :delete, class: 'gl-button btn btn-danger-secondary'
+ = link_button_to _('Remove avatar'), project_avatar_path(@project), aria: { label: _('Remove avatar') }, data: { confirm: _('Avatar will be removed. Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :delete, variant: :danger, category: :secondary
= f.submit _('Save changes'), pajamas_button: true, class: "gl-mt-6", data: { qa_selector: 'save_naming_topics_avatar_button' }
diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml
index df517b5d642..e4af6d59cad 100644
--- a/app/views/projects/settings/access_tokens/index.html.haml
+++ b/app/views/projects/settings/access_tokens/index.html.haml
@@ -4,31 +4,28 @@
- type_plural = _('project access tokens')
- @force_desktop_expanded_sidebar = true
-.row.gl-mt-3.js-search-settings-section
- .col-lg-4
- %h4.gl-mt-0
- = page_title
- %p
- - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/project_access_tokens') }
+.gl-mt-5.js-search-settings-section
+ %h4.gl-my-0
+ = page_title
+ %p.gl-text-secondary
+ - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/project_access_tokens') }
- if current_user.can?(:create_resource_access_tokens, @project)
= _('Generate project access tokens scoped to this project for your applications that need access to the GitLab API.')
- %p
- = _('You can also use project access tokens with Git to authenticate over HTTP(S). %{link_start}Learn more.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ = _('You can also use project access tokens with Git to authenticate over HTTP(S). %{link_start}Learn more.%{link_end}').html_safe % { link_start: help_link_start, link_end: '</a>'.html_safe }
- else
- = _('Project access token creation is disabled in this group. You can still use and manage existing tokens. %{link_start}Learn more.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- %p
+ = _('Project access token creation is disabled in this group.')
- root_group = @project.group.root_ancestor
- if current_user.can?(:admin_group, root_group)
- group_settings_link = edit_group_path(root_group)
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_settings_link }
= _('You can enable project access token creation in %{link_start}group settings%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ = html_escape(_('You can still use and manage existing tokens. %{link_start}Learn more.%{link_end}')) % { link_start: help_link_start, link_end: '</a>'.html_safe }
- .col-lg-8
- #js-new-access-token-app{ data: { access_token_type: type } }
+ #js-new-access-token-app{ data: { access_token_type: type } }
- - if current_user.can?(:create_resource_access_tokens, @project)
- = render_if_exists 'projects/settings/access_tokens/form',
- type: type
+ - if current_user.can?(:create_resource_access_tokens, @project)
+ = render_if_exists 'projects/settings/access_tokens/form',
+ type: type
- #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true
+ #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true
} }
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index 6f64d3f3f76..6eccbd245af 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -75,7 +75,7 @@
= f.number_field :max_artifacts_size, class: 'form-control gl-form-input'
%p.form-text.text-muted
= _("The maximum file size in megabytes for individual job artifacts.")
- = link_to sprite_icon('question-o'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to sprite_icon('question-o'), help_page_path('administration/settings/continuous_integration', anchor: 'maximum-artifacts-size'), target: '_blank', rel: 'noopener noreferrer'
= f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index c7bb6a7f5da..007169809c9 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -33,12 +33,13 @@
= render_if_exists 'projects/settings/ci_cd/protected_environments', expanded: expanded
-%section.settings.no-animate#js-runners-settings{ class: ('expanded' if expanded || params[:expand_runners]), data: { qa_selector: 'runners_settings_content' } }
+- expand_runners = expanded || params[:expand_runners]
+%section.settings.no-animate#js-runners-settings{ class: ('expanded' if expand_runners), data: { qa_selector: 'runners_settings_content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _("Runners")
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
- = expanded ? _('Collapse') : _('Expand')
+ = expand_runners ? _('Collapse') : _('Expand')
%p
= _("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'
diff --git a/app/views/projects/settings/slacks/edit.html.haml b/app/views/projects/settings/slacks/edit.html.haml
index 867b90655e3..537ae767b1d 100644
--- a/app/views/projects/settings/slacks/edit.html.haml
+++ b/app/views/projects/settings/slacks/edit.html.haml
@@ -17,4 +17,4 @@
.footer-block.row-content-block
= form.submit _('Save changes'), pajamas_button: true
&nbsp;
- = link_to _('Cancel'), edit_project_settings_integration_path(@project, @service), class: 'btn gl-button btn-cancel'
+ = link_button_to _('Cancel'), edit_project_settings_integration_path(@project, @service)
diff --git a/app/views/projects/snippets/index.html.haml b/app/views/projects/snippets/index.html.haml
index 7c936c849d0..ae9a8307eb9 100644
--- a/app/views/projects/snippets/index.html.haml
+++ b/app/views/projects/snippets/index.html.haml
@@ -9,7 +9,7 @@
- if new_project_snippet_link.present?
.nav-controls
- = link_to _("New snippet"), new_project_snippet_link, class: "gl-button btn btn-confirm", title: _("New snippet")
+ = link_button_to _("New snippet"), new_project_snippet_link, title: _("New snippet"), variant: :confirm
= render 'shared/snippets/list'
- else
diff --git a/app/views/projects/tags/_edit_release_button.html.haml b/app/views/projects/tags/_edit_release_button.html.haml
index 9a6c18df2ca..42af8d4f59f 100644
--- a/app/views/projects/tags/_edit_release_button.html.haml
+++ b/app/views/projects/tags/_edit_release_button.html.haml
@@ -1,9 +1,8 @@
- release_btn_text = s_('TagsPage|Create release')
- release_btn_path = new_project_release_path(project, tag_name: tag.name)
- option_css_classes = local_assigns.fetch(:option_css_classes, '')
-- css_classes = "btn gl-button btn-default btn-icon btn-edit has-tooltip #{option_css_classes}"
- if release
- release_btn_text = s_('TagsPage|Edit release')
- release_btn_path = edit_project_release_path(project, release)
-= link_to release_btn_path, class: css_classes do
+= render Pajamas::ButtonComponent.new(href: release_btn_path, button_options: { class: option_css_classes }) do
= release_btn_text
diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml
index fda797f3228..b0be748eb36 100644
--- a/app/views/projects/tags/index.html.haml
+++ b/app/views/projects/tags/index.html.haml
@@ -9,10 +9,9 @@
.nav-controls
#js-tags-sort-dropdown{ data: { filter_tags_path: filter_tags_path(search: @search, sort: @sort), sort_options: tags_sort_options_hash.to_json } }
- = link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn gl-button btn-default btn-icon has-tooltip gl-ml-auto' do
- = sprite_icon('rss', css_class: 'gl-icon')
+ = link_button_to nil, project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'has-tooltip gl-ml-auto', icon: 'rss'
- if can?(current_user, :admin_tag, @project)
- = link_to new_project_tag_path(@project), class: 'btn gl-button btn-confirm', data: { qa_selector: "new_tag_button" } do
+ = link_button_to new_project_tag_path(@project), data: { qa_selector: "new_tag_button" }, variant: :confirm do
= s_('TagsPage|New tag')
= render_if_exists 'projects/commits/mirror_status'
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index 5127972c406..1649e56043e 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -45,10 +45,8 @@
= render partial: 'projects/commit/signature', object: @tag.signature
- if can?(current_user, :admin_tag, @project)
= render 'edit_release_button', tag: @tag, project: @project, release: @release
- = link_to project_tree_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default has-tooltip', title: s_('TagsPage|Browse files') do
- = sprite_icon('folder-open', css_class: 'gl-icon')
- = link_to project_commits_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default has-tooltip', title: s_('TagsPage|Browse commits') do
- = sprite_icon('history', css_class: 'gl-icon')
+ = link_button_to nil, project_tree_path(@project, @tag.name), class: 'has-tooltip', title: s_('TagsPage|Browse files'), icon: 'folder-open'
+ = link_button_to nil, project_commits_path(@project, @tag.name), class: 'has-tooltip', title: s_('TagsPage|Browse commits'), icon: 'history'
= render 'projects/buttons/download', project: @project, ref: @tag.name
- if can?(current_user, :admin_tag, @project)
= render 'projects/buttons/remove_tag', project: @project, tag: @tag
diff --git a/app/views/projects/tracing/index.html.haml b/app/views/projects/tracing/index.html.haml
new file mode 100644
index 00000000000..ae6608cf343
--- /dev/null
+++ b/app/views/projects/tracing/index.html.haml
@@ -0,0 +1,4 @@
+- page_title _('Tracing')
+
+#js-tracing{ data: { view_model: observability_tracing_view_model(@project) } }
+
diff --git a/app/views/protected_branches/shared/_protected_branch.html.haml b/app/views/protected_branches/shared/_protected_branch.html.haml
index c2a5dd8a9b0..69969b7f848 100644
--- a/app/views/protected_branches/shared/_protected_branch.html.haml
+++ b/app/views/protected_branches/shared/_protected_branch.html.haml
@@ -27,4 +27,4 @@
%span.has-tooltip{ data: { container: 'body' }, title: s_('ProtectedBranch|Inherited - This setting can be changed at the group level'), 'aria-hidden': 'true' }
= sprite_icon 'lock'
- else
- = 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"
+ = link_button_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, variant: :danger, size: :small
diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml
index 986bc53fd81..caaa209a702 100644
--- a/app/views/registrations/welcome/show.html.haml
+++ b/app/views/registrations/welcome/show.html.haml
@@ -8,6 +8,7 @@
= render "layouts/one_trust"
= render "layouts/bizible"
= render "layouts/google_tag_manager_body"
+
.row.gl-flex-grow-1
.d-flex.gl-flex-direction-column.gl-align-items-center.gl-w-full.gl-px-5.gl-pb-5
.edit-profile.login-page.d-flex.flex-column.gl-align-items-center
diff --git a/app/views/search/results/_issuable.html.haml b/app/views/search/results/_issuable.html.haml
index 188ead4008e..a896cbc5cba 100644
--- a/app/views/search/results/_issuable.html.haml
+++ b/app/views/search/results/_issuable.html.haml
@@ -1,4 +1,4 @@
-%div{ class: 'search-result-row gl-display-flex gl-sm-flex-direction-row gl-flex-direction-column gl-align-items-center gl-pb-3! gl-mt-5 gl-mb-0!' }
+%div{ class: 'search-result-row gl-display-flex gl-sm-flex-direction-row gl-flex-direction-column gl-align-items-center gl-pb-5! gl-mt-5 gl-mb-0!' }
.col-sm-9
%span.gl-display-flex.gl-align-items-center
= gl_badge_tag issuable_state_text(issuable), variant: issuable_state_to_badge_class(issuable), size: :sm
@@ -11,6 +11,11 @@
= sprintf(s_('created %{issuable_created} by %{author}'), { issuable_created: time_ago_with_tooltip(issuable.created_at, placement: 'bottom'), author: link_to_member(@project, issuable.author, avatar: false) }).html_safe
.description.term.gl-px-0
= highlight_and_truncate_issuable(issuable, @search_term, @search_highlight)
+ - if issuable.labels.any?
+ .gl-mt-3
+ - presented_labels_sorted_by_title(issuable.labels, issuable.project).each do |label|
+ = link_to_label(label, small: true)
+
.col-sm-3.gl-mt-3.gl-sm-mt-0.gl-text-right
- if issuable.respond_to?(:upvotes_count) && issuable.upvotes_count > 0
%li.gl-list-style-none
diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml
index d6900c397a0..08d8ffcf250 100644
--- a/app/views/search/results/_wiki_blob.html.haml
+++ b/app/views/search/results/_wiki_blob.html.haml
@@ -1,9 +1,6 @@
-- project = wiki_blob.project
-- wiki_blob_link = project_wiki_path(project, wiki_blob.basename)
-
%div{ class: 'search-result-row gl-pb-3! gl-mt-5 gl-mb-0!' }
%span.gl-display-flex.gl-align-items-center
- = link_to wiki_blob_link, data: { track_action: 'click_text', track_label: "wiki_title", track_property: 'search_result' }, class: 'gl-w-full' do
+ = link_to wiki_blob_link(wiki_blob), data: { track_action: 'click_text', track_label: "wiki_title", track_property: 'search_result' }, class: 'gl-w-full' do
%span.term.str-truncated.gl-font-weight-bold= ::Wiki.canonicalize_filename(wiki_blob.path)
.description.term.col-sm-10.gl-px-0
= simple_search_highlight_and_truncate(wiki_blob.data, @search_term)
diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml
index 03b030eb257..16e4ff4d17f 100644
--- a/app/views/sent_notifications/unsubscribe.html.haml
+++ b/app/views/sent_notifications/unsubscribe.html.haml
@@ -16,4 +16,4 @@
%p
= link_to _('Unsubscribe'), unsubscribe_sent_notification_path(@sent_notification, force: true),
class: 'gl-button btn btn-confirm gl-mr-3'
- = link_to _('Cancel'), new_user_session_path, class: 'gl-button btn gl-mr-3'
+ = link_button_to _('Cancel'), new_user_session_path, class: 'gl-mr-3'
diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
index f4af3ea70d4..79a9bafc4f0 100644
--- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
+++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
@@ -8,5 +8,5 @@
%div
= _('Container registry is not enabled on this GitLab instance. Ask an administrator to enable it in order for Auto DevOps to work.')
- c.with_actions do
- = link_to _('Settings'), project_settings_ci_cd_path(project), class: 'alert-link btn gl-button btn-confirm'
- = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link btn gl-button btn-default gl-ml-3'
+ = link_button_to _('Settings'), project_settings_ci_cd_path(project), class: 'alert-link', variant: :confirm
+ = link_button_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link gl-ml-3'
diff --git a/app/views/shared/_md_preview.html.haml b/app/views/shared/_md_preview.html.haml
index dd3a31f5a59..1fd430527a1 100644
--- a/app/views/shared/_md_preview.html.haml
+++ b/app/views/shared/_md_preview.html.haml
@@ -1,4 +1,5 @@
- referenced_users = local_assigns.fetch(:referenced_users, nil)
+- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false)
- if @merge_request&.discussion_locked?
.issuable-note-warning
@@ -8,14 +9,14 @@
= _('Only project members can comment.')
.md-area.position-relative
- .md-header.gl-bg-gray-50.gl-px-2.gl-rounded-base.gl-mx-2.gl-mt-2
+ .md-header.gl-px-3.gl-rounded-top-base.gl-border-b.gl-border-gray-100
.gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-justify-content-space-between
- .md-header-toolbar.gl-display-flex.gl-py-2.gl-flex-wrap
- = render 'shared/blob/markdown_buttons'
- .switch-preview.gl-py-2.gl-display-flex.gl-align-items-center.gl-ml-auto
+ .md-header-toolbar.gl-display-flex.gl-py-3.gl-flex-wrap.gl-row-gap-3
= render Pajamas::ButtonComponent.new(category: :tertiary, size: :small, button_options: { class: 'js-md-preview-button', value: 'preview' }) do
= _('Preview')
- = render Pajamas::ButtonComponent.new(icon: 'maximize', category: :tertiary, size: :small, button_options: { 'tabindex': -1, 'aria-label': _("Go full screen"), class: 'has-tooltip js-zen-enter gl-ml-2', data: { container: 'body' } })
+ = render 'shared/blob/markdown_buttons', supports_quick_actions: supports_quick_actions
+ .full-screen
+ = render Pajamas::ButtonComponent.new(icon: 'maximize', category: :tertiary, size: :small, button_options: { 'tabindex': -1, 'aria-label': _("Go full screen"), class: 'has-tooltip js-zen-enter', data: { container: 'body' } })
.md-write-holder
= yield
diff --git a/app/views/shared/_new_merge_request_checkbox.html.haml b/app/views/shared/_new_merge_request_checkbox.html.haml
index fb3dfba2691..b84efd2d577 100644
--- a/app/views/shared/_new_merge_request_checkbox.html.haml
+++ b/app/views/shared/_new_merge_request_checkbox.html.haml
@@ -2,7 +2,8 @@
- nonce = SecureRandom.hex
= render Pajamas::CheckboxTagComponent.new(name: 'create_merge_request',
checked: true,
- checkbox_options: { class: 'js-create-merge-request', id: "create_merge_request-#{nonce}" }) do |c|
+ checkbox_options: { class: 'js-create-merge-request', id: "create_merge_request-#{nonce}" },
+ label_options: { for: "create_merge_request-#{nonce}" }) do |c|
- c.with_label do
- translation_variables = { new_merge_request: "<strong>#{_('new merge request')}</strong>" }
- translation = _('Start a %{new_merge_request} with these changes') % translation_variables
diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml
index e9c0858e090..a3f24da5d7c 100644
--- a/app/views/shared/_no_ssh.html.haml
+++ b/app/views/shared/_no_ssh.html.haml
@@ -5,5 +5,5 @@
- c.with_body do
= s_("MissingSSHKeyWarningLink|You can't push or pull repositories using SSH until you add an SSH key to your profile.")
- c.with_actions do
- = link_to s_('MissingSSHKeyWarningLink|Add SSH key'), profile_keys_path, class: "gl-alert-action btn btn-confirm btn-md gl-button"
- = link_to s_("MissingSSHKeyWarningLink|Don't show again"), profile_path(user: { hide_no_ssh_key: true }), method: :put, role: 'button', class: 'gl-alert-action btn btn-default btn-md gl-button'
+ = link_button_to s_('MissingSSHKeyWarningLink|Add SSH key'), profile_keys_path, class: 'gl-alert-action', variant: :confirm
+ = link_button_to s_("MissingSSHKeyWarningLink|Don't show again"), profile_path(user: { hide_no_ssh_key: true }), method: :put, role: 'button', class: 'gl-alert-action'
diff --git a/app/views/shared/_project_limit.html.haml b/app/views/shared/_project_limit.html.haml
index ce49193e27b..a99db32c40e 100644
--- a/app/views/shared/_project_limit.html.haml
+++ b/app/views/shared/_project_limit.html.haml
@@ -5,5 +5,5 @@
- c.with_body do
= _("You won't be able to create new projects because you have reached your project limit.")
- c.with_actions do
- = link_to _('Remind later'), '#', class: 'alert-link hide-project-limit-message btn gl-button btn-confirm'
- = link_to _("Don't show again"), profile_path(user: {hide_project_limit: true}), method: :put, class: 'alert-link btn gl-button btn-default gl-ml-3'
+ = link_button_to _('Remind later'), '#', class: 'alert-link hide-project-limit-message', variant: :confirm
+ = link_button_to _("Don't show again"), profile_path(user: {hide_project_limit: true}), method: :put, class: 'alert-link gl-ml-3'
diff --git a/app/views/shared/_prometheus_configuration_banner.html.haml b/app/views/shared/_prometheus_configuration_banner.html.haml
index 2d948cf28a6..7469260a997 100644
--- a/app/views/shared/_prometheus_configuration_banner.html.haml
+++ b/app/views/shared/_prometheus_configuration_banner.html.haml
@@ -17,11 +17,11 @@
.col-sm-10
%p.text-success.gl-mt-3
= s_('PrometheusService|You have a cluster with the Prometheus integration enabled.')
- = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn gl-button btn-default'
+ = link_button_to s_('PrometheusService|Manage clusters'), project_clusters_path(project)
- else
.col-sm-2
= image_tag 'illustrations/monitoring/loading.svg'
.col-sm-10
%p.gl-mt-3
= s_('PrometheusService|Configure GitLab to query a Prometheus installed in one of your clusters.')
- = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn gl-button btn-confirm'
+ = link_button_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), variant: :confirm
diff --git a/app/views/shared/_registration_features_discovery_message.html.haml b/app/views/shared/_registration_features_discovery_message.html.haml
index 053c511830c..6e386866dfb 100644
--- a/app/views/shared/_registration_features_discovery_message.html.haml
+++ b/app/views/shared/_registration_features_discovery_message.html.haml
@@ -1,5 +1,5 @@
- feature_title = local_assigns.fetch(:feature_title, s_('RegistrationFeatures|use this feature'))
-- registration_features_docs_path = help_page_path('user/admin_area/settings/usage_statistics.md', anchor: 'registration-features-program')
+- registration_features_docs_path = help_page_path('administration/settings/usage_statistics.md', anchor: 'registration-features-program')
- registration_features_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: registration_features_docs_path }
%div
diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml
index bc80ebe3950..fa5c862b768 100644
--- a/app/views/shared/_remote_mirror_update_button.html.haml
+++ b/app/views/shared/_remote_mirror_update_button.html.haml
@@ -3,5 +3,4 @@
button_options: { class: 'disabled', title: _('Updating'), data: { toggle: 'tooltip', container: 'body' } },
icon_classes: 'spin')
- elsif remote_mirror.enabled?
- = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn btn-icon gl-button rspec-update-now-button", data: { toggle: 'tooltip', container: 'body', qa_selector: 'update_now_button' }, title: _('Update now') do
- = sprite_icon("retry")
+ = link_button_to nil, update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: 'rspec-update-now-button', data: { toggle: 'tooltip', container: 'body', qa_selector: 'update_now_button' }, title: _('Update now'), icon: 'retry'
diff --git a/app/views/shared/_service_ping_consent.html.haml b/app/views/shared/_service_ping_consent.html.haml
index 108d846e3ee..e0313710736 100644
--- a/app/views/shared/_service_ping_consent.html.haml
+++ b/app/views/shared/_service_ping_consent.html.haml
@@ -1,11 +1,14 @@
- if session[:ask_for_usage_stats_consent]
= render Pajamas::AlertComponent.new(alert_options: { class: 'service-ping-consent-message' }) do |c|
- c.with_body do
- - docs_link = link_to _('collect usage information'), help_page_path('user/admin_area/settings/usage_statistics.md'), class: 'gl-link'
- - settings_link = link_to _('your settings'), metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), class: 'gl-link'
- = s_('To help improve GitLab, we would like to periodically %{docs_link}. This can be changed at any time in %{settings_link}.').html_safe % { docs_link: docs_link, settings_link: settings_link }
+ - docs_link = link_to '', help_page_path('user/admin_area/settings/usage_statistics.md'), class: 'gl-link'
+ - settings_link = link_to '', metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), class: 'gl-link'
+ = safe_format s_('ServicePing|To help improve GitLab, we would like to periodically %{link_start}collect usage information%{link_end}.'), tag_pair(docs_link, :link_start, :link_end)
+ = safe_format s_('ServicePing|This can be changed at any time in %{link_start}your settings%{link_end}.'), tag_pair(settings_link, :link_start, :link_end)
- c.with_actions do
- send_service_data_path = admin_application_settings_path(application_setting: { version_check_enabled: 1, usage_ping_enabled: 1 })
- not_now_path = admin_application_settings_path(application_setting: { version_check_enabled: 0, usage_ping_enabled: 0 })
- = link_to _("Send service data"), send_service_data_path, 'data-url' => admin_application_settings_path, method: :put, 'data-check-enabled': true, 'data-service-ping-enabled': true, class: 'js-service-ping-consent-action alert-link btn gl-button btn-confirm'
- = link_to _("Don't send service data"), not_now_path, 'data-url' => admin_application_settings_path, method: :put, 'data-check-enabled': false, 'data-service-ping-enabled': false, class: 'js-service-ping-consent-action alert-link btn gl-button btn-default gl-ml-3'
+ = render Pajamas::ButtonComponent.new(href: send_service_data_path, method: :put, variant: :confirm, button_options: { 'data-url' => admin_application_settings_path, 'data-check-enabled': true, 'data-service-ping-enabled': true, class: 'js-service-ping-consent-action alert-link' }) do
+ = _('Send service data')
+ = render Pajamas::ButtonComponent.new(href: not_now_path, method: :put, button_options: { 'data-url' => admin_application_settings_path, 'data-check-enabled': false, 'data-service-ping-enabled': false, class: 'js-service-ping-consent-action alert-link gl-ml-3' }) do
+ = _("Don't send service data")
diff --git a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml
index 290152d5803..e372dbd983c 100644
--- a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml
+++ b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml
@@ -8,5 +8,5 @@
= s_('Profiles|Ensure you have two-factor authentication recovery codes stored in a safe place.')
= link_to _('Learn more.'), help_page_path('user/profile/account/two_factor_authentication', anchor: 'recovery-codes'), target: '_blank', rel: 'noopener noreferrer'
- c.with_actions do
- = link_to profile_two_factor_auth_path, class: 'deferred-link btn gl-alert-action btn-confirm btn-md gl-button' do
+ = link_button_to profile_two_factor_auth_path, class: 'deferred-link gl-alert-action', variant: :confirm do
= s_('Profiles|Manage two-factor authentication')
diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml
index ac359d37c49..54af364aca3 100644
--- a/app/views/shared/access_tokens/_form.html.haml
+++ b/app/views/shared/access_tokens/_form.html.haml
@@ -7,36 +7,27 @@
- access_levels = local_assigns.fetch(:access_levels, false)
- default_access_level = local_assigns.fetch(:default_access_level, false)
-%h5.gl-mt-0
+%h5.gl-font-lg.gl-mt-0
= title
-%p.profile-settings-content
- = s_("AccessTokens|Enter the name of your application, and we'll return a unique %{type}.") % { type: type }
= gitlab_ui_form_for token, as: prefix, url: path, method: :post, html: { id: 'js-new-access-token-form', class: 'js-requires-input' }, remote: ajax do |f|
-
= form_errors(token)
- .row
- .form-group.col
- .row
- = f.label :name, s_('AccessTokens|Token name'), class: 'label-bold col-md-12'
- .col-md-6
- - resource_type = resource.is_a?(Group) ? "group" : "project"
- = f.text_field :name, class: 'form-control gl-form-input', required: true, data: { qa_selector: 'access_token_name_field' }, :'aria-describedby' => 'access_token_help_text'
- %span.form-text.text-muted.col-md-12#access_token_help_text= s_("AccessTokens|For example, the application using the token or the purpose of the token. Do not give sensitive information for the name of the token, as it will be visible to all %{resource_type} members.") % { resource_type: resource_type }
+ .form-group
+ = f.label :name, s_('AccessTokens|Token name'), class: 'label-bold'
+ - resource_type = resource.is_a?(Group) ? "group" : "project"
+ = f.text_field :name, class: 'form-control gl-form-input gl-max-w-80', required: true, data: { qa_selector: 'access_token_name_field' }, :'aria-describedby' => 'access_token_help_text'
+ %span.form-text.text-muted#access_token_help_text= s_("AccessTokens|For example, the application using the token or the purpose of the token. Do not give sensitive information for the name of the token, as it will be visible to all %{resource_type} members.") % { resource_type: resource_type }
- .row
- .col
- .js-access-tokens-expires-at{ data: expires_at_field_data }
- = f.text_field :expires_at, class: 'gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { js_name: 'expiresAt' }
+ .js-access-tokens-expires-at{ data: expires_at_field_data }
+ = f.text_field :expires_at, class: 'gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { js_name: 'expiresAt' }
- if resource
- .row
- .form-group.col-md-6
- = label_tag :access_level, s_("AccessTokens|Select a role"), class: "label-bold"
- .select-wrapper
- = select_tag :"#{prefix}[access_level]", options_for_select(access_levels, default_access_level), class: "form-control select-control", data: { qa_selector: 'access_token_access_level' }
- = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200")
+ .form-group
+ = label_tag :access_level, s_("AccessTokens|Select a role"), class: "label-bold"
+ .select-wrapper.gl-form-input-md
+ = select_tag :"#{prefix}[access_level]", options_for_select(access_levels, default_access_level), class: "form-control select-control", data: { qa_selector: 'access_token_access_level' }
+ = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200")
.form-group
%b{ :'aria-describedby' => 'select_scope_help_text' }
diff --git a/app/views/shared/blob/_markdown_buttons.html.haml b/app/views/shared/blob/_markdown_buttons.html.haml
index a3d3c1c8231..16bffaca810 100644
--- a/app/views/shared/blob/_markdown_buttons.html.haml
+++ b/app/views/shared/blob/_markdown_buttons.html.haml
@@ -1,32 +1,33 @@
- modifier_key = client_js_flags[:isMac] ? '⌘' : s_('KeyboardKey|Ctrl+')
- supports_file_upload = local_assigns.fetch(:supports_file_upload, true)
+- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false)
= markdown_toolbar_button({ icon: "bold",
- css_class: 'gl-mr-3',
+ css_class: 'haml-markdown-button gl-mr-3',
data: { "md-tag" => "**", "md-shortcuts": '["mod+b"]' },
title: sprintf(s_("MarkdownEditor|Add bold text (%{modifier_key}B)") % { modifier_key: modifier_key }) })
= markdown_toolbar_button({ icon: "italic",
- css_class: 'gl-mr-3',
+ css_class: 'haml-markdown-button gl-mr-3',
data: { "md-tag" => "_", "md-shortcuts": '["mod+i"]' },
title: sprintf(s_("MarkdownEditor|Add italic text (%{modifier_key}I)") % { modifier_key: modifier_key }) })
= markdown_toolbar_button({ icon: "strikethrough",
- css_class: 'gl-mr-3',
+ css_class: 'haml-markdown-button gl-mr-3',
data: { "md-tag" => "~~", "md-shortcuts": '["mod+shift+x"]' },
title: sprintf(s_("MarkdownEditor|Add strikethrough text (%{modifier_key}⇧X)") % { modifier_key: modifier_key }) })
-= markdown_toolbar_button({ icon: "quote", css_class: 'gl-mr-3', data: { "md-tag" => "> ", "md-prepend" => true }, title: _("Insert a quote") })
-= markdown_toolbar_button({ icon: "code", css_class: 'gl-mr-3', data: { "md-tag" => "`", "md-block" => "```" }, title: _("Insert code") })
+= markdown_toolbar_button({ icon: "quote", css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "> ", "md-prepend" => true }, title: _("Insert a quote") })
+= markdown_toolbar_button({ icon: "code", css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "`", "md-block" => "```" }, title: _("Insert code") })
= markdown_toolbar_button({ icon: "link",
- css_class: 'gl-mr-3',
+ css_class: 'haml-markdown-button gl-mr-3',
data: { "md-tag" => "[{text}](url)", "md-select" => "url", "md-shortcuts": '["mod+k"]' },
title: sprintf(s_("MarkdownEditor|Add a link (%{modifier_key}K)") % { modifier_key: modifier_key }) })
-= markdown_toolbar_button({ icon: "list-bulleted", css_class: 'gl-mr-3', data: { "md-tag" => "- ", "md-prepend" => true }, title: _("Add a bullet list") })
-= markdown_toolbar_button({ icon: "list-numbered", css_class: 'gl-mr-3', data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") })
-= markdown_toolbar_button({ icon: "list-task", css_class: 'gl-mr-3', data: { "md-tag" => "- [ ] ", "md-prepend" => true }, title: _("Add a checklist") })
+= markdown_toolbar_button({ icon: "list-bulleted", css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "- ", "md-prepend" => true }, title: _("Add a bullet list") })
+= markdown_toolbar_button({ icon: "list-numbered", css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") })
+= markdown_toolbar_button({ icon: "list-task", css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "- [ ] ", "md-prepend" => true }, title: _("Add a checklist") })
= markdown_toolbar_button({ icon: "list-indent",
css_class: 'gl-display-none gl-mr-3',
data: { "md-command" => 'indentLines', "md-shortcuts": '["mod+]"]' },
@@ -36,9 +37,11 @@
data: { "md-command" => 'outdentLines', "md-shortcuts": '["mod+["]' },
title: sprintf(s_("MarkdownEditor|Outdent line (%{modifier_key}[)") % { modifier_key: modifier_key }) })
= markdown_toolbar_button({ icon: "details-block",
- css_class: 'gl-mr-3',
+ css_class: 'haml-markdown-button gl-mr-3',
data: { "md-tag" => "<details><summary>Click to expand</summary>\n{text}\n</details>", "md-prepend" => true, "md-select" => "Click to expand" },
title: _("Add a collapsible section") })
-= markdown_toolbar_button({ icon: "table", css_class: 'gl-mr-3', data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| | |\n| | |", "md-prepend" => true }, title: _("Add a table") })
+= markdown_toolbar_button({ icon: "table", css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| | |\n| | |", "md-prepend" => true }, title: _("Add a table") })
- if supports_file_upload
- = render Pajamas::ButtonComponent.new(icon: 'paperclip', category: :tertiary, size: :small, button_options: { 'aria-label': _("Attach a file or image"), class: 'has-tooltip js-attach-file-button gl-mr-3', data: { testid: 'button-attach-file', container: 'body' } })
+ = render Pajamas::ButtonComponent.new(icon: 'paperclip', category: :tertiary, size: :small, button_options: { 'aria-label': _("Attach a file or image"), class: 'has-tooltip js-attach-file-button haml-markdown-button gl-mr-3', data: { testid: 'button-attach-file', container: 'body' } })
+- if supports_quick_actions
+ = markdown_toolbar_button({ icon: "quick-actions", css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "/", "md-prepend" => true }, title: _("Add a quick action") })
diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml
index 0f290f34a95..8821804ce6b 100644
--- a/app/views/shared/deploy_tokens/_form.html.haml
+++ b/app/views/shared/deploy_tokens/_form.html.haml
@@ -1,4 +1,4 @@
-%p.profile-settings-content
+%p
- group_deploy_tokens_help_link_url = help_page_path('user/project/deploy_tokens/index.md')
- group_deploy_tokens_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_deploy_tokens_help_link_url }
= s_('DeployTokens|Create a new deploy token for all projects in this group. %{link_start}What are deploy tokens?%{link_end}').html_safe % { link_start: group_deploy_tokens_help_link_start, link_end: '</a>'.html_safe }
diff --git a/app/views/shared/deploy_tokens/_table.html.haml b/app/views/shared/deploy_tokens/_table.html.haml
index a7bf3bfb81e..3827ecf73a4 100644
--- a/app/views/shared/deploy_tokens/_table.html.haml
+++ b/app/views/shared/deploy_tokens/_table.html.haml
@@ -16,7 +16,7 @@
%tr
%td= token.name
%td= token.username
- %td= token.created_at.to_date.to_s(:medium)
+ %td= token.created_at.to_date.to_fs(:medium)
%td
- if token.expires?
%span{ class: ('text-warning' if token.expires_soon?) }
diff --git a/app/views/shared/doorkeeper/applications/_form.html.haml b/app/views/shared/doorkeeper/applications/_form.html.haml
index 628a34e1278..ae539c46cf1 100644
--- a/app/views/shared/doorkeeper/applications/_form.html.haml
+++ b/app/views/shared/doorkeeper/applications/_form.html.haml
@@ -1,4 +1,4 @@
-= gitlab_ui_form_for @application, url: url, html: { role: 'form', class: 'doorkeeper-app-form' } do |f|
+= gitlab_ui_form_for @application, url: url, html: { role: 'form', class: 'doorkeeper-app-form gl-max-w-80' } do |f|
= form_errors(@application)
.form-group
diff --git a/app/views/shared/doorkeeper/applications/_index.html.haml b/app/views/shared/doorkeeper/applications/_index.html.haml
index abfe3baf8b4..bf78f275d65 100644
--- a/app/views/shared/doorkeeper/applications/_index.html.haml
+++ b/app/views/shared/doorkeeper/applications/_index.html.haml
@@ -1,88 +1,86 @@
- @force_desktop_expanded_sidebar = true
-.row.gl-mt-3.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = page_title
- %p
- - if oauth_applications_enabled
- - if oauth_authorized_applications_enabled
- = _("Manage applications that can use GitLab as an OAuth provider, and applications that you've authorized to use your account.")
- - else
- = _("Manage applications that use GitLab as an OAuth provider.")
- - else
- = _("Manage applications that you've authorized to use your account.")
- .col-lg-8
+.settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = page_title
+ %p.gl-text-secondary
- if oauth_applications_enabled
- %h5.gl-mt-0
- = _('Add new application')
- = render 'shared/doorkeeper/applications/form', url: form_url
- %hr
+ - if oauth_authorized_applications_enabled
+ = _("Manage applications that can use GitLab as an OAuth provider, and applications that you've authorized to use your account.")
+ - else
+ = _("Manage applications that use GitLab as an OAuth provider.")
- else
- .bs-callout.bs-callout-disabled
- = _('Adding new applications is disabled in your GitLab instance. Please contact your GitLab administrator to get the permission')
- - if oauth_applications_enabled
- .oauth-applications
- %h5
- = _("Your applications (%{size})") % { size: @applications.size }
- - if @applications.any?
- .table-responsive
- %table.table
- %thead
- %tr
- %th= _('Name')
- %th= _('Callback URL')
- %th= _('Clients')
- %th.last-heading
- %tbody
- - @applications.each do |application|
- %tr{ id: "application_#{application.id}" }
- %td= link_to application.name, application_url.call(application)
- %td
- - application.redirect_uri.split.each do |uri|
- %div= uri
- %td= application.access_tokens.count
- %td.gl-display-flex
- = link_to edit_application_url.call(application), class: "gl-button btn btn-default btn-icon gl-mr-3" do
- %span.sr-only
- = _('Edit')
- = sprite_icon('pencil')
- = render 'shared/doorkeeper/applications/delete_form', path: application_url.call(application), small: true
- - else
- .settings-message.text-center
- = _("You don't have any applications")
- - if oauth_authorized_applications_enabled
- .oauth-authorized-applications.prepend-top-20.gl-mb-3
- - if oauth_applications_enabled
- %h5
- = _("Authorized applications (%{size})") % { size: @authorized_tokens.size }
+ = _("Manage applications that you've authorized to use your account.")
+ - if oauth_applications_enabled
+ %h5.gl-mt-0
+ = _('Add new application')
+ .gl-border-b.gl-pb-6
+ = render 'shared/doorkeeper/applications/form', url: form_url
+
+ - else
+ .bs-callout.bs-callout-disabled
+ = _('Adding new applications is disabled in your GitLab instance. Please contact your GitLab administrator to get the permission')
+ - if oauth_applications_enabled
+ .oauth-applications.gl-pt-6
+ %h5.gl-mt-0
+ = _("Your applications (%{size})") % { size: @applications.size }
+ - if @applications.any?
+ .table-responsive
+ %table.table
+ %thead
+ %tr
+ %th= _('Name')
+ %th= _('Callback URL')
+ %th= _('Clients')
+ %th.last-heading
+ %tbody
+ - @applications.each do |application|
+ %tr{ id: "application_#{application.id}" }
+ %td= link_to application.name, application_url.call(application)
+ %td
+ - application.redirect_uri.split.each do |uri|
+ %div= uri
+ %td= application.access_tokens.count
+ %td.gl-display-flex
+ = link_button_to nil, edit_application_url.call(application), class: 'gl-mr-3', icon: 'pencil', 'aria-label': _('Edit')
+ = render 'shared/doorkeeper/applications/delete_form', path: application_url.call(application), small: true
+ - else
+ .settings-message
+ = _("You don't have any applications")
+ - if oauth_authorized_applications_enabled
+ .oauth-authorized-applications.gl-mt-4
+ - if oauth_applications_enabled
+ %h5.gl-mt-0
+ = _("Authorized applications (%{size})") % { size: @authorized_tokens.size }
- - if @authorized_tokens.any?
- .table-responsive
- %table.table.table-striped
- %thead
- %tr
- %th= _('Name')
- %th= _('Authorized At')
- %th= _('Scope')
- %th
- %tbody
- - @authorized_tokens.each do |token|
- %tr{ id: ("application_#{token.application.id}" if token.application) }
- %td
- - if token.application
- = token.application.name
- - else
- = _('Anonymous')
- .form-text.text-muted
- %em= _("Authorization was granted by entering your username and password in the application.")
- %td= token.created_at
- %td= token.scopes
- %td
- - if token.application
- = render 'doorkeeper/authorized_applications/delete_form', application: token.application
- - else
- = render 'doorkeeper/authorized_applications/delete_form', token: token
- - else
- .settings-message.text-center
- = _("You don't have any authorized applications")
+ - if @authorized_tokens.any?
+ .table-responsive
+ %table.table.table-striped
+ %thead
+ %tr
+ %th= _('Name')
+ %th= _('Authorized At')
+ %th= _('Scope')
+ %th
+ %tbody
+ - @authorized_tokens.each do |token|
+ %tr{ id: ("application_#{token.application.id}" if token.application) }
+ %td
+ - if token.application
+ = token.application.name
+ - else
+ = _('Anonymous')
+ .form-text.text-muted
+ %em= _("Authorization was granted by entering your username and password in the application.")
+ %td= token.created_at
+ %td= token.scopes
+ %td
+ - if token.application
+ = render 'doorkeeper/authorized_applications/delete_form', application: token.application
+ - else
+ = render 'doorkeeper/authorized_applications/delete_form', token: token
+ - else
+ .settings-message
+ = _("You don't have any authorized applications")
diff --git a/app/views/shared/doorkeeper/applications/_show.html.haml b/app/views/shared/doorkeeper/applications/_show.html.haml
index b9095e2a1a1..b075cece877 100644
--- a/app/views/shared/doorkeeper/applications/_show.html.haml
+++ b/app/views/shared/doorkeeper/applications/_show.html.haml
@@ -43,8 +43,8 @@
.form-actions.gl-display-flex.gl-justify-content-space-between
%div
- if @created
- = link_to _('Continue'), index_path, class: 'btn btn-confirm btn-md gl-button gl-mr-3'
- = link_to _('Edit'), edit_path, class: 'btn btn-default btn-md gl-button'
+ = link_button_to _('Continue'), index_path, class: 'gl-mr-3', variant: :confirm
+ = link_button_to _('Edit'), edit_path
= render 'shared/doorkeeper/applications/delete_form', path: delete_path
-# Create a hidden field to save the ID of application created
diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml
index 387a83873b5..a4ea98a0fb7 100644
--- a/app/views/shared/empty_states/_issues.html.haml
+++ b/app/views/shared/empty_states/_issues.html.haml
@@ -19,7 +19,7 @@
= _("To widen your search, change or remove filters above")
- if show_new_issue_link?(@project)
.text-center
- = link_to _("New issue"), new_project_issue_path(@project), class: "gl-button btn btn-confirm"
+ = link_button_to _("New issue"), new_project_issue_path(@project), variant: :confirm
- elsif is_opened_state && opened_issues_count == 0 && closed_issues_count > 0
%h4.text-center
= _("There are no open issues")
@@ -27,7 +27,7 @@
= _("To keep this project going, create a new issue")
- if show_new_issue_link?(@project)
.text-center
- = link_to _("New issue"), new_project_issue_path(@project), class: "gl-button btn btn-confirm"
+ = link_button_to _("New issue"), new_project_issue_path(@project), variant: :confirm
- elsif is_closed_state && opened_issues_count > 0 && closed_issues_count == 0
%h4.text-center
= _("There are no closed issues")
@@ -39,7 +39,7 @@
- if button_path
.text-center
- if show_new_issue_link?(@project)
- = link_to _('New issue'), button_path, class: 'gl-button btn btn-confirm', id: 'new_issue_link'
+ = link_button_to _('New issue'), button_path, id: 'new_issue_link', variant: :confirm
- if show_import_button
.js-csv-import-export-buttons{ data: { show_import_button: 'true', issuable_type: 'issue', import_csv_issues_path: import_csv_namespace_project_issues_path, can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) } }
@@ -59,4 +59,4 @@
%p
= _("The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.")
.text-center
- = link_to _('Register / Sign In'), new_user_session_path, class: 'gl-button btn btn-confirm'
+ = link_button_to _('Register / Sign In'), new_user_session_path, variant: :confirm
diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml
index da88c139a6e..4d2127c0161 100644
--- a/app/views/shared/empty_states/_labels.html.haml
+++ b/app/views/shared/empty_states/_labels.html.haml
@@ -8,7 +8,7 @@
%p= _("You can also star a label to make it a priority label.")
.text-center
- if can?(current_user, :admin_label, @project)
- = link_to _('New label'), new_project_label_path(@project), class: 'btn gl-button btn-confirm', title: _('New label'), id: 'new_label_link'
- = link_to _('Generate a default set of labels'), generate_project_labels_path(@project), method: :post, class: 'btn gl-button btn-confirm-secondary', title: _('Generate a default set of labels'), id: 'generate_labels_link'
+ = link_button_to _('New label'), new_project_label_path(@project), title: _('New label'), id: 'new_label_link', variant: :confirm
+ = link_button_to _('Generate a default set of labels'), generate_project_labels_path(@project), method: :post, title: _('Generate a default set of labels'), id: 'generate_labels_link', variant: :confirm, category: :secondary
- if can?(current_user, :admin_label, @group)
- = link_to _('New label'), new_group_label_path(@group), class: 'btn gl-button btn-confirm', title: _('New label'), id: 'new_label_link'
+ = link_button_to _('New label'), new_group_label_path(@group), title: _('New label'), id: 'new_label_link', variant: :confirm
diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml
index 94589996c3a..5b377818c6e 100644
--- a/app/views/shared/empty_states/_merge_requests.html.haml
+++ b/app/views/shared/empty_states/_merge_requests.html.haml
@@ -18,7 +18,7 @@
= _("To widen your search, change or remove filters above")
.text-center
- if can_create_merge_request
- = link_to _("New merge request"), button_path || project_new_merge_request_path(@project), class: "gl-button btn btn-confirm", title: _("New merge request")
+ = link_button_to _("New merge request"), button_path || project_new_merge_request_path(@project), title: _("New merge request"), variant: :confirm
- elsif is_opened_state && opened_merged_count == 0 && closed_merged_count > 0
%h4.text-center
= _("There are no open merge requests")
@@ -26,7 +26,7 @@
= _("To keep this project going, create a new merge request")
.text-center
- if can_create_merge_request
- = link_to _("New merge request"), button_path || project_new_merge_request_path(@project), class: "gl-button btn btn-confirm", title: _("New merge request")
+ = link_button_to _("New merge request"), button_path || project_new_merge_request_path(@project), title: _("New merge request"), variant: :confirm
- elsif is_closed_state && opened_merged_count > 0 && closed_merged_count == 0
%h4.text-center
= _("There are no closed merge requests")
@@ -37,4 +37,4 @@
= _("Interested parties can even contribute by pushing commits if they want to.")
- if button_path
.text-center
- = link_to _('New merge request'), button_path, class: 'gl-button btn btn-confirm', title: _('New merge request'), id: 'new_merge_request_link', data: { qa_selector: "new_merge_request_button" }
+ = link_button_to _('New merge request'), button_path, title: _('New merge request'), id: 'new_merge_request_link', data: { qa_selector: "new_merge_request_button" }, variant: :confirm
diff --git a/app/views/shared/empty_states/_priority_labels.html.haml b/app/views/shared/empty_states/_priority_labels.html.haml
index b24fa0b3bdb..688df1705aa 100644
--- a/app/views/shared/empty_states/_priority_labels.html.haml
+++ b/app/views/shared/empty_states/_priority_labels.html.haml
@@ -1,8 +1,8 @@
-.text-center.gl-mt-1.gl-mb-6
+.text-center.gl-mt-1.gl-mb-5
.svg-content{ data: { qa_selector: 'label_svg_content' } }
= image_tag 'illustrations/empty-state/empty-labels-starred-md.svg'
- if can?(current_user, :admin_label, @project)
- %div
+ %h5.gl-my-0
= _("No prioritized labels yet!")
- %div
+ %p.gl-text-secondary
= _("Star labels to start sorting by priority.")
diff --git a/app/views/shared/empty_states/_profile_tabs.html.haml b/app/views/shared/empty_states/_profile_tabs.html.haml
index c813fd691f1..ba5fbd90528 100644
--- a/app/views/shared/empty_states/_profile_tabs.html.haml
+++ b/app/views/shared/empty_states/_profile_tabs.html.haml
@@ -13,9 +13,9 @@
%p= current_user_empty_message_description
- if secondary_button_link.present?
- = link_to secondary_button_label, secondary_button_link, class: 'gl-button btn btn-confirm btn-inverted'
+ = link_button_to secondary_button_label, secondary_button_link, variant: :confirm, category: :secondary
- if primary_button_link.present?
- = link_to primary_button_label, primary_button_link, class: 'gl-button btn btn-confirm'
+ = link_button_to primary_button_label, primary_button_link, variant: :confirm
- else
%h5= visitor_empty_message
diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml
index 87de756093d..6fe36d75453 100644
--- a/app/views/shared/empty_states/_snippets.html.haml
+++ b/app/views/shared/empty_states/_snippets.html.haml
@@ -12,7 +12,7 @@
= s_('SnippetsEmptyState|Store, share, and embed small pieces of code and text.')
.gl-mt-3<
- if button_path
- = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn gl-button btn-confirm', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { qa_selector: 'create_first_snippet_link' }
- = link_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets.md'), class: 'btn gl-button btn-default', title: s_('SnippetsEmptyState|Documentation')
+ = link_button_to s_('SnippetsEmptyState|New snippet'), button_path, title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { qa_selector: 'create_first_snippet_link' }, variant: :confirm
+ = link_button_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets.md'), title: s_('SnippetsEmptyState|Documentation')
- else
%h4.gl-text-center= s_('SnippetsEmptyState|There are no snippets to show.')
diff --git a/app/views/shared/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml
index 57f1c9d381e..9e628a1f409 100644
--- a/app/views/shared/empty_states/_wikis.html.haml
+++ b/app/views/shared/empty_states/_wikis.html.haml
@@ -4,7 +4,7 @@
- if !hide_create && can?(current_user, :create_wiki, @wiki.container)
- create_path = wiki_page_path(@wiki, params[:id], view: 'create')
- - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn gl-button btn-confirm', title: s_('WikiEmpty|Create your first page'), data: { qa_selector: 'create_first_page_link' }
+ - create_link = link_button_to s_('WikiEmpty|Create your first page'), create_path, title: s_('WikiEmpty|Create your first page'), data: { qa_selector: 'create_first_page_link' }, variant: :confirm
= render layout: layout_path, locals: { image_path: 'illustrations/wiki_login_empty.svg' } do
%h4.text-left
@@ -26,7 +26,7 @@
%p.text-left
= messages.dig(:issuable, :body).html_safe % { issues_link: issues_link }
- if show_new_issue_link?(@project)
- = link_to s_('WikiEmpty|Suggest wiki improvement'), new_project_issue_path(@project), class: 'btn gl-button btn-confirm', title: s_('WikiEmptyIssueMessage|Suggest wiki improvement')
+ = link_button_to s_('WikiEmpty|Suggest wiki improvement'), new_project_issue_path(@project), title: s_('WikiEmptyIssueMessage|Suggest wiki improvement'), variant: :confirm
- else
= render layout: layout_path, locals: { image_path: 'illustrations/wiki_logout_empty.svg' } do
diff --git a/app/views/shared/file_hooks/_index.html.haml b/app/views/shared/file_hooks/_index.html.haml
index ba968c6b2d2..9f1b11d6ab5 100644
--- a/app/views/shared/file_hooks/_index.html.haml
+++ b/app/views/shared/file_hooks/_index.html.haml
@@ -1,26 +1,30 @@
- file_hooks = Gitlab::FileHook.files
-.row.gl-mt-3
- .col-lg-4
- %h4.gl-mt-0
- = _('File Hooks')
- %p
- = _('File hooks are similar to system hooks but are executed as files instead of sending data to a URL.')
- = link_to _('For more information, see the File Hooks documentation.'), help_page_path('administration/file_hooks')
+.settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = _('File Hooks')
+ %p.gl-text-secondary
+ = _('File hooks are similar to system hooks but are executed as files instead of sending data to a URL.')
+ = link_to _('For more information, see the File Hooks documentation.'), help_page_path('administration/file_hooks')
-
- .col-lg-8.gl-mb-3
- - if file_hooks.any?
- = render Pajamas::CardComponent.new do |c|
- - c.with_header do
- = _('File Hooks (%{count})') % { count: file_hooks.count }
- - c.with_body do
- %ul.content-list
- - file_hooks.each do |file|
- %li
- .monospace
- = File.basename(file)
- - else
- = render Pajamas::CardComponent.new do |c|
- - c.with_body do
- .nothing-here-block= _('No file hooks found.')
+ .gl-mb-3
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header'}, body_options: { class: 'gl-new-card-body'}) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h3.gl-new-card-title
+ = _('File Hooks')
+ %span.gl-new-card-count
+ = sprite_icon('hook', css_class: 'gl-mr-2')
+ #{file_hooks.count}
+ - c.with_body do
+ .gl-new-card-content
+ - if file_hooks.any?
+ %ul.content-list{ class: 'gl-my-n3!' }
+ - file_hooks.each do |file|
+ %li.label-list-item
+ .monospace
+ = File.basename(file)
+ - else
+ .gl-new-card-empty.gl-text-center= _('No file hooks found.')
diff --git a/app/views/shared/form_elements/_apply_generated_description_warning.haml b/app/views/shared/form_elements/_apply_generated_description_warning.haml
new file mode 100644
index 00000000000..906f60d01e6
--- /dev/null
+++ b/app/views/shared/form_elements/_apply_generated_description_warning.haml
@@ -0,0 +1,13 @@
+.form-group.row.js-ai-description-warning.hidden.js-issuable-ai-description-warning
+ .col-sm-12
+ .warning_message.mb-0{ role: 'alert' }
+ %btn.js-close-btn.js-dismiss-btn.close{ type: "button", "aria-hidden": "true", "aria-label": _("Close") }
+ = sprite_icon("close")
+
+ %p
+ = s_("AI|Replace the existing description with an AI-generated description? Any changes you have made will be lost.")
+
+ = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'gl-mr-3 js-ai-override-description' }) do
+ = s_("AI|Apply AI-generated description")
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-cancel-btn' }) do
+ = _("Cancel")
diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
index 415849672b6..75f678dea5c 100644
--- a/app/views/shared/form_elements/_description.html.haml
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -15,12 +15,15 @@
= render 'shared/issuable/form/template_selector', issuable: model
= render 'shared/form_elements/apply_template_warning', issuable: model
+ - if model.is_a?(MergeRequest)
+ = render 'shared/form_elements/apply_generated_description_warning', issuable: model
.js-markdown-editor{ data: { render_markdown_path: preview_url,
markdown_docs_path: help_page_path('user/markdown'),
quick_actions_docs_path: help_page_path('user/project/quick_actions'),
qa_selector: 'issuable_form_description_field',
form_field_placeholder: placeholder,
+ autofocus: 'false',
form_field_classes: 'js-gfm-input markdown-area note-textarea rspec-issuable-form-description' } }
= form.hidden_field :description
diff --git a/app/views/shared/hook_logs/_index.html.haml b/app/views/shared/hook_logs/_index.html.haml
index 7dab14b95c1..ee7d5b79560 100644
--- a/app/views/shared/hook_logs/_index.html.haml
+++ b/app/views/shared/hook_logs/_index.html.haml
@@ -2,10 +2,9 @@
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url }
- link_end = '</a>'.html_safe
-.row.gl-mt-3.gl-mb-3
- .col-lg-3
- %h4.gl-mt-0
- = _('Recent events')
- %p= _('GitLab events trigger webhooks. Use the request details of a webhook to help troubleshoot problems. %{link_start}How do I troubleshoot?%{link_end}').html_safe % { link_start: link_start, link_end: link_end }
- .col-lg-9
- = render partial: 'shared/hook_logs/recent_deliveries_table', locals: { hook: hook, hook_logs: hook_logs }
+.settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0= _('Recent events')
+ %p.gl-text-secondary= _('GitLab events trigger webhooks. Use the request details of a webhook to help troubleshoot problems. %{link_start}How do I troubleshoot?%{link_end}').html_safe % { link_start: link_start, link_end: link_end }
+ = render partial: 'shared/hook_logs/recent_deliveries_table', locals: { hook: hook, hook_logs: hook_logs }
diff --git a/app/views/shared/integrations/gitlab_slack_application/_slack_button.html.haml b/app/views/shared/integrations/gitlab_slack_application/_slack_button.html.haml
deleted file mode 100644
index b22a6eeca90..00000000000
--- a/app/views/shared/integrations/gitlab_slack_application/_slack_button.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-= link_to add_to_slack_link(project, slack_app_id), class: 'btn btn-default gl-button gl-pr-6!' do
- = image_tag 'illustrations/slack_logo.svg', class: 'gl-icon gl-button-icon gl-w-9! gl-h-9! gl-my-n3! gl-mr-0!'
- %strong.gl-button-text
- = label
diff --git a/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml b/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml
index 5c9f77f8c12..e5d05a8a83d 100644
--- a/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml
+++ b/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml
@@ -20,13 +20,16 @@
%td{ class: 'gl-py-3!' }
= time_ago_with_tooltip(slack_integration.created_at)
%td{ class: 'gl-py-3!' }
- .controls
+ .controls.gl-display-flex.gl-gap-3
- project = integration.project
- = link_to _('Edit'), edit_project_settings_slack_path(project), class: 'btn gl-button btn-default'
- = link_to sprite_icon('remove', css_class: 'gl-icon'), project_settings_slack_path(project), method: :delete, class: 'btn gl-button btn-danger btn-danger-secondary', aria: { label: s_('SlackIntegration|Remove project') }, data: { confirm_btn_variant: "danger", confirm: s_('SlackIntegration|Are you sure you want to remove this project from the GitLab for Slack app?') }
+ = render Pajamas::ButtonComponent.new(href: edit_project_settings_slack_path(project)) do
+ = _('Edit')
+ = render Pajamas::ButtonComponent.new(method: :delete, category: 'secondary', variant: "danger", href: project_settings_slack_path(project), icon: 'remove', button_options: { aria: { label: s_('SlackIntegration|Remove project') }, data: { confirm_btn_variant: "danger", confirm: s_('SlackIntegration|Are you sure you want to remove this project from the GitLab for Slack app?') }})
.gl-my-5
- = render 'shared/integrations/gitlab_slack_application/slack_button', project: @project, label: s_('SlackIntegration|Reinstall GitLab for Slack app')
+ = render Pajamas::ButtonComponent.new(href: add_to_slack_link(@project, slack_app_id)) do
+ = s_('SlackIntegration|Reinstall GitLab for Slack app…')
%p
= html_escape(s_('SlackIntegration|You may need to reinstall the GitLab for Slack app when we %{linkStart}make updates or change permissions%{linkEnd}.')) % { linkStart: %(<a href="#{help_page_path('user/project/integrations/gitlab_slack_application.md', anchor: 'update-the-gitlab-for-slack-app')}">).html_safe, linkEnd: '</a>'.html_safe}
- else
- = render 'shared/integrations/gitlab_slack_application/slack_button', project: @project, label: s_('SlackIntegration|Install GitLab for Slack app')
+ = render Pajamas::ButtonComponent.new(href: add_to_slack_link(@project, slack_app_id)) do
+ = s_('SlackIntegration|Install GitLab for Slack app…')
diff --git a/app/views/shared/integrations/prometheus/_custom_metrics.html.haml b/app/views/shared/integrations/prometheus/_custom_metrics.html.haml
index 0264196f60c..a7a650aa95d 100644
--- a/app/views/shared/integrations/prometheus/_custom_metrics.html.haml
+++ b/app/views/shared/integrations/prometheus/_custom_metrics.html.haml
@@ -11,7 +11,7 @@
%strong
= s_('PrometheusService|Custom metrics')
= gl_badge_tag 0, nil, class: 'gl-ml-2 js-custom-monitored-count'
- = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn gl-button btn-confirm gl-ml-auto js-new-metric-button hidden'
+ = link_button_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'gl-ml-auto js-new-metric-button hidden', variant: :confirm
- c.with_body do
.flash-container.hidden
.flash-warning
diff --git a/app/views/shared/integrations/slack_slash_commands/_help.html.haml b/app/views/shared/integrations/slack_slash_commands/_help.html.haml
index fee0ca15808..43a240fa6fe 100644
--- a/app/views/shared/integrations/slack_slash_commands/_help.html.haml
+++ b/app/views/shared/integrations/slack_slash_commands/_help.html.haml
@@ -57,7 +57,7 @@
= label_tag nil, _('Customize icon'), class: 'col-12 col-form-label label-bold'
.col-12
= image_tag(asset_url('slash-command-logo.png', skip_pipeline: true), width: 36, height: 36, class: 'mr-3')
- = link_to(_('Download image'), asset_url('gitlab_logo.png'), class: 'gl-button btn btn-default btn-sm', target: '_blank', rel: 'noopener noreferrer')
+ = link_button_to _('Download image'), asset_url('gitlab_logo.png'), target: '_blank', rel: 'noopener noreferrer', size: :small
.form-group
= label_tag nil, _('Autocomplete'), class: 'col-12 col-form-label label-bold'
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index b6bd691213c..42f035b99aa 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -61,14 +61,14 @@
= form.submit _('Save changes'), pajamas_button: true, class: 'gl-mr-2 js-issuable-submit-button js-reset-autosave', data: { track_action: 'click_button', track_label: 'submit_mr', track_value: 0 }
- if issuable.new_record?
- = link_to _('Cancel'), polymorphic_path([@project, issuable.class]), class: 'btn gl-button btn-default js-reset-autosave'
+ = link_button_to _('Cancel'), polymorphic_path([@project, issuable.class]), class: 'js-reset-autosave'
- else
- = link_to _('Cancel'), polymorphic_path([@project, issuable]), class: 'gl-button btn btn-default js-reset-autosave'
+ = link_button_to _('Cancel'), polymorphic_path([@project, issuable]), class: 'js-reset-autosave'
- if can?(current_user, :"destroy_#{issuable.to_ability_name}", @project)
- confirm_title = _('Delete %{issuableType}?') % { issuableType: issuable.human_class_name }
- confirm_body = _('You’re about to permanently delete the %{issuableType} ‘%{strongOpen}%{issuableTitle}%{strongClose}’. To avoid data loss, consider %{strongOpen}closing this %{issuableType}%{strongClose} instead. Once deleted, it cannot be undone or recovered.') % { issuableType: issuable.human_class_name, issuableTitle: issuable.title, strongOpen: '<strong>', strongClose: '</strong>' }
- confirm_primary_btn_text = _('Delete %{issuableType}') % { issuableType: issuable.human_class_name }
- = link_to _('Delete'), polymorphic_path([@project, issuable], params: { destroy_confirm: true }), data: { title: confirm_title, confirm: confirm_body, is_html_message: true, confirm_btn_variant: 'danger'}, method: :delete, class: 'btn gl-button btn-danger btn-danger-secondary gl-float-right js-reset-autosave', "aria-label": confirm_primary_btn_text
+ = link_button_to _('Delete'), polymorphic_path([@project, issuable], params: { destroy_confirm: true }), data: { title: confirm_title, confirm: confirm_body, is_html_message: true, confirm_btn_variant: 'danger'}, method: :delete, class: 'gl-float-right js-reset-autosave', "aria-label": confirm_primary_btn_text, variant: :danger, category: :secondary
- if issuable.respond_to?(:issue_type)
= form.hidden_field :issue_type
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index b8f98c28574..d590c859945 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -34,7 +34,7 @@
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
@@ -44,7 +44,7 @@
#js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item{ data: { value: "{{ title }}" } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
{{ title }}
%span.btn-helptext
{{ help }}
@@ -60,10 +60,10 @@
#js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('None')
%li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('Any')
%li.divider.droplab-item-ignore
- if current_user
@@ -76,10 +76,10 @@
#js-dropdown-reviewer.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('None')
%li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('Any')
%li.divider.droplab-item-ignore
- if current_user
@@ -94,101 +94,101 @@
#js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('None')
%li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('Any')
%li.filter-dropdown-item{ data: { value: 'Upcoming' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('Upcoming')
%li.filter-dropdown-item{ data: { value: 'Started' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('Started')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
- %button.gl-button.btn.btn-link.js-data-value{ type: 'button' }
+ %button.btn.btn-link.js-data-value{ type: 'button' }
{{title}}
= render_if_exists 'shared/issuable/filter_iteration', type: type
#js-dropdown-release.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('None')
%li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('Any')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
- %button.gl-button.btn.btn-link.js-data-value{ type: 'button' }
+ %button.btn.btn-link.js-data-value{ type: 'button' }
{{title}}
#js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('None')
%li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('Any')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
%span.dropdown-label-box{ style: 'background: {{color}}' }
%span.label-title.js-data-value
{{title}}
#js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('None')
%li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('Any')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
%gl-emoji
%span.js-data-value.gl-ml-3
{{name}}
#js-dropdown-wip.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('Yes')
%li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('No')
- if ::Feature.enabled?(:mr_approved_filter, type: :ops)
#js-dropdown-approved.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('Yes')
%li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('No')
#js-dropdown-confidential.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('Yes')
%li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
- %button.gl-button.btn.btn-link{ type: 'button' }
+ %button.btn.btn-link{ type: 'button' }
= _('No')
- unless disable_target_branch
#js-dropdown-target-branch.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
- %button.gl-button.btn.btn-link.js-data-value.monospace
+ %button.btn.btn-link.js-data-value.monospace
{{title}}
#js-dropdown-environment.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
- %button.gl-button.btn.btn-link.js-data-value{ type: 'button' }
+ %button.btn.btn-link.js-data-value{ type: 'button' }
{{title}}
= render_if_exists 'shared/issuable/filter_weight', type: type
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index ee1ca364b07..fadaeafeaf6 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -12,10 +12,10 @@
- moved_sidebar_enabled = moved_mr_sidebar_enabled?
- is_merge_request_with_flag = is_merge_request && moved_sidebar_enabled
-%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in }, issuable_type: issuable_type }, class: "#{sidebar_gutter_collapsed_class(is_merge_request_with_flag)} #{'right-sidebar-merge-requests' if is_merge_request_with_flag}", 'aria-live' => 'polite', 'aria-label': issuable_type }
+%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { always_show_toggle: true, signed: { in: signed_in }, issuable_type: issuable_type }, class: "#{sidebar_gutter_collapsed_class(is_merge_request_with_flag)} #{'right-sidebar-merge-requests' if is_merge_request_with_flag}", 'aria-live' => 'polite', 'aria-label': issuable_type }
.issuable-sidebar{ class: "#{'is-merge-request' if is_merge_request_with_flag}" }
.issuable-sidebar-header{ class: "#{'gl-pb-2! gl-md-display-flex gl-justify-content-end gl-lg-display-none!' if is_merge_request_with_flag}" }
- %button.btn.gl-button.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ type: "reset", class: "gl-shadow-none! #{'gl-display-block' if moved_sidebar_enabled}", "aria-label" => _('Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }
+ = render Pajamas::ButtonComponent.new(button_options: { class: "gutter-toggle float-right js-sidebar-toggle has-tooltip gl-shadow-none! #{'gl-display-block' if moved_sidebar_enabled}", type: 'button', 'aria-label' => _('Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }) do
= sidebar_gutter_toggle_icon
- if signed_in && !is_merge_request_with_flag
.js-sidebar-todo-widget-root{ data: { project_path: issuable_sidebar[:project_full_path], iid: issuable_sidebar[:iid], id: issuable_sidebar[:id] } }
diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml
index c6f3e4d97a8..a27bb506c87 100644
--- a/app/views/shared/issuable/_sidebar_assignees.html.haml
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -2,7 +2,6 @@
- dropdown_options = assignees_dropdown_options(issuable_type)
.js-sidebar-assignees-root{ data: { field: issuable_type,
- signed_in: signed_in,
max_assignees: dropdown_options[:data][:"max-select"],
directly_invite_members: can_admin_project_member?(@project) } }
.title.hide-collapsed
diff --git a/app/views/shared/issuable/_status_box.html.haml b/app/views/shared/issuable/_status_box.html.haml
index 125ef921cfa..cca51b48322 100644
--- a/app/views/shared/issuable/_status_box.html.haml
+++ b/app/views/shared/issuable/_status_box.html.haml
@@ -2,7 +2,7 @@
- badge_icon = state_name_with_icon(issuable)[1]
- badge_variant = issuable.open? ? :success : issuable.merged? ? :info : :danger
- badge_status_class = issuable.open? ? 'issuable-status-badge-open' : issuable.merged? ? 'issuable-status-badge-merged' : 'issuable-status-badge-closed'
-- badge_classes = "js-mr-status-box issuable-status-badge gl-mr-3 #{badge_status_class} #{'gl-vertical-align-bottom' if issuable.is_a?(MergeRequest)}"
+- badge_classes = "js-mr-status-box issuable-status-badge gl-mr-3 gl-align-self-center #{badge_status_class} #{'gl-vertical-align-bottom' if issuable.is_a?(MergeRequest)}"
= gl_badge_tag({ variant: badge_variant, icon: badge_icon, icon_classes: 'gl-mr-0!' }, { class: badge_classes, data: { project_path: issuable.project.path_with_namespace, iid: issuable.iid, issuable_type: 'merge_request', state: issuable.state } }) do
%span.gl-display-none.gl-sm-display-block.gl-ml-2
diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml
index b6c0b73a83d..4997d429587 100644
--- a/app/views/shared/issue_type/_details_header.html.haml
+++ b/app/views/shared/issue_type/_details_header.html.haml
@@ -16,7 +16,6 @@
#js-issuable-header-warnings{ data: { hidden: issue_hidden?(issuable).to_s } }
= issuable_meta(issuable, @project)
- %a.btn.gl-button.btn-default.btn-icon.float-right.gl-display-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
- = sprite_icon('chevron-double-lg-left')
+ = render Pajamas::ButtonComponent.new(href: '#', icon: 'chevron-double-lg-left', button_options: { class: 'gl-float-right gl-display-block gl-sm-display-none! gutter-toggle issuable-gutter-toggle js-sidebar-toggle' })
.js-issue-header-actions{ data: issue_header_actions_data(@project, issuable, current_user, @issuable_sidebar) }
diff --git a/app/views/shared/members/_manage_access_button.html.haml b/app/views/shared/members/_manage_access_button.html.haml
index c88198ec380..910d62d4dc4 100644
--- a/app/views/shared/members/_manage_access_button.html.haml
+++ b/app/views/shared/members/_manage_access_button.html.haml
@@ -1,7 +1,5 @@
- path = local_assigns.fetch(:path, nil)
.gl-float-right
- = link_to path, class: 'btn btn-default btn-sm gl-button' do
- = sprite_icon('pencil', css_class: 'gl-icon gl-button-icon')
- %span.gl-button-text
- = _('Manage access')
+ = link_button_to path, size: :small, icon: 'pencil' do
+ = _('Manage access')
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index 376e51a6b15..c86993f5b77 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -43,7 +43,7 @@
= _("Given access %{time_ago}").html_safe % { time_ago: time_ago_with_tooltip(member.created_at) }
%span.js-expires-in{ class: ('gl-display-none' unless member.expires?) }
&middot;
- %span.js-expires-in-text{ class: "has-tooltip#{' text-warning' if member.expires_soon?}", title: (member.expires_at.to_time.in_time_zone.to_s(:medium) if member.expires?) }
+ %span.js-expires-in-text{ class: "has-tooltip#{' text-warning' if member.expires_soon?}", title: (member.expires_at.to_time.in_time_zone.to_fs(:medium) if member.expires?) }
- if member.expires?
- preposition = current_user.time_display_relative ? '' : 'on'
= _("Expires %{preposition} %{expires_at}").html_safe % { expires_at: time_ago_with_tooltip(member.expires_at), preposition: preposition }
diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml
index d2bee57992d..1e856bf4355 100644
--- a/app/views/shared/milestones/_labels_tab.html.haml
+++ b/app/views/shared/milestones/_labels_tab.html.haml
@@ -8,7 +8,7 @@
= markdown_field(label, :description)
.float-right.d-none.d-lg-block
- = link_to milestones_issues_path(options.merge(state: 'opened')), class: 'btn gl-button btn-default-tertiary' do
- - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), _('open issue')
- = link_to milestones_issues_path(options.merge(state: 'closed')), class: 'btn gl-button btn-default-tertiary' do
- - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), _('closed issue')
+ = link_button_to milestones_issues_path(options.merge(state: 'opened')), category: :tertiary do
+ = n_('open issue', 'open issues', milestone_issues_by_label_count(@milestone, label, state: :opened))
+ = link_button_to milestones_issues_path(options.merge(state: 'closed')), category: :tertiary do
+ = n_('closed issue', 'closed issues', milestone_issues_by_label_count(@milestone, label, state: :closed))
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index 01548325c83..c36d3a8b92b 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -52,6 +52,6 @@
= render Pajamas::ButtonComponent.new(icon: 'level-up', category: :tertiary, size: :small, button_options: { class: 'js-promote-project-milestone-button', title: s_('Milestones|Promote to Group Milestone'), disabled: true, data: { toggle: 'tooltip', container: 'body', url: promote_project_milestone_path(milestone.project, milestone), milestone_title: milestone.title, group_name: @project.group.name } })
- if can?(current_user, :admin_milestone, milestone)
- if milestone.closed?
- = link_to s_('Milestones|Reopen Milestone'), milestone_path(milestone, milestone: { state_event: :activate }), method: :put, class: "btn gl-button btn-sm gl-ml-3"
+ = link_button_to s_('Milestones|Reopen Milestone'), milestone_path(milestone, milestone: { state_event: :activate }), method: :put, class: 'gl-ml-3', size: :small
- else
- = link_to s_('Milestones|Close Milestone'), milestone_path(milestone, milestone: { state_event: :close }), method: :put, class: "btn gl-button btn-default btn-default-secondary btn-sm gl-ml-3"
+ = link_button_to s_('Milestones|Close Milestone'), milestone_path(milestone, milestone: { state_event: :close }), method: :put, class: 'gl-ml-3', category: :secondary, size: :small
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index 5477b9395ea..1b0eeb424c2 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -26,7 +26,7 @@
.value
%span.value-content{ data: { qa_selector: 'start_date_content' } }
- if milestone.start_date
- %span.bold= milestone.start_date.to_s(:medium)
+ %span.bold= milestone.start_date.to_fs(:medium)
- else
%span.no-value= s_('MilestoneSidebar|No start date')
@@ -63,7 +63,7 @@
.value.hide-collapsed
%span.value-content{ data: { qa_selector: 'due_date_content' } }
- if milestone.due_date
- %span.bold= milestone.due_date.to_s(:medium)
+ %span.bold= milestone.due_date.to_fs(:medium)
- else
%span.no-value= s_('MilestoneSidebar|No due date')
- remaining_days = remaining_days_in_words(milestone.due_date, milestone.start_date)
diff --git a/app/views/shared/nav/_sidebar.html.haml b/app/views/shared/nav/_sidebar.html.haml
index 915352996d9..91b0582e04a 100644
--- a/app/views/shared/nav/_sidebar.html.haml
+++ b/app/views/shared/nav/_sidebar.html.haml
@@ -1,6 +1,6 @@
%aside.nav-sidebar{ class: ('sidebar-collapsed-desktop' if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(sidebar.container), 'aria-label': sidebar.aria_label }
.nav-sidebar-inner-scroll
- %ul.sidebar-top-level-items{ data: { qa_selector: sidebar_qa_selector(sidebar.container) } }
+ %ul.sidebar-top-level-items{ data: { testid: sidebar_qa_selector(sidebar.container) } }
- if sidebar.render_raw_scope_menu_partial
= render sidebar.render_raw_scope_menu_partial
- elsif sidebar.scope_menu
diff --git a/app/views/shared/nav/_sidebar_menu.html.haml b/app/views/shared/nav/_sidebar_menu.html.haml
index bc0648c14e0..27f77ed4813 100644
--- a/app/views/shared/nav/_sidebar_menu.html.haml
+++ b/app/views/shared/nav/_sidebar_menu.html.haml
@@ -2,7 +2,7 @@
- if sidebar_menu.menu_with_partial?
= render_if_exists sidebar_menu.menu_partial, **sidebar_menu.menu_partial_options
- else
- = link_to sidebar_menu.link, **sidebar_menu.link_html_options, data: { qa_selector: 'sidebar_menu_link', qa_menu_item: sidebar_menu.title } do
+ = link_to sidebar_menu.link, **sidebar_menu.link_html_options, data: { testid: 'sidebar_menu_link', qa_menu_item: sidebar_menu.title } do
- if sidebar_menu.icon_or_image?
%span.nav-icon-container
- if sidebar_menu.image_path
diff --git a/app/views/shared/nav/_sidebar_menu_item.html.haml b/app/views/shared/nav/_sidebar_menu_item.html.haml
index eea36127745..ef488d06e87 100644
--- a/app/views/shared/nav/_sidebar_menu_item.html.haml
+++ b/app/views/shared/nav/_sidebar_menu_item.html.haml
@@ -1,5 +1,5 @@
= nav_link(**sidebar_menu_item.active_routes, html_options: sidebar_menu_item.nav_link_html_options) do
- = link_to sidebar_menu_item.link, **sidebar_menu_item.link_html_options, data: { qa_selector: 'sidebar_menu_item_link', qa_menu_item: sidebar_menu_item.title } do
+ = link_to sidebar_menu_item.link, **sidebar_menu_item.link_html_options, data: { testid: 'sidebar_menu_item_link', qa_menu_item: sidebar_menu_item.title } do
%span.gl-flex-grow-1
= sidebar_menu_item.title
- if sidebar_menu_item.sprite_icon
diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml
index 98008fede90..9a5e9b2179f 100644
--- a/app/views/shared/notes/_form.html.haml
+++ b/app/views/shared/notes/_form.html.haml
@@ -5,7 +5,7 @@
- else
- preview_url = preview_markdown_path(@project)
-= form_for form_resources, url: new_form_url, remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form discussion-reply-holder", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f|
+= form_for form_resources, url: new_form_url, remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form discussion-reply-holder gl-border-top-0!", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f|
= hidden_field_tag :view, diff_view
= hidden_field_tag :line_type
= hidden_field_tag :merge_request_diff_head_sha, @note.noteable.try(:diff_head_sha)
@@ -25,14 +25,14 @@
= f.hidden_field :position
.discussion-form-container.discussion-with-resolve-btn.flex-column.p-0
- = render layout: 'shared/md_preview', locals: { url: preview_url, referenced_users: true } do
+ = render layout: 'shared/md_preview', locals: { url: preview_url, referenced_users: true, supports_quick_actions: supports_quick_actions } do
= render 'shared/zen', f: f, qa_selector: 'note_field',
attr: :note,
classes: 'note-textarea js-note-text',
placeholder: _("Write a comment or drag your files here…"),
supports_quick_actions: supports_quick_actions,
supports_autocomplete: supports_autocomplete
- = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions
+ = render 'shared/notes/hints'
.error-alert
.note-form-actions.clearfix.gl-display-flex.gl-flex-wrap
diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml
index d7d6e477ab1..23ce38d50e0 100644
--- a/app/views/shared/notes/_hints.html.haml
+++ b/app/views/shared/notes/_hints.html.haml
@@ -1,13 +1,7 @@
-- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false)
- supports_file_upload = local_assigns.fetch(:supports_file_upload, true)
-.comment-toolbar.gl-mx-2.gl-mb-2.gl-px-4.gl-bg-gray-10.gl-rounded-bottom-left-base.gl-rounded-bottom-right-base.clearfix
- .toolbar-text.gl-font-sm
- - markdownLinkStart = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/markdown') }
- - quickActionsLinkStart = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/quick_actions') }
- - if supports_quick_actions
- = html_escape(s_('NoteToolbar|Supports %{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd}. For %{quickActionsDocsLinkStart}quick actions%{quickActionsDocsLinkEnd}, type %{keyboardStart}/%{keyboardEnd}.')) % { markdownDocsLinkStart: markdownLinkStart, markdownDocsLinkEnd: '</a>'.html_safe, quickActionsDocsLinkStart: quickActionsLinkStart, quickActionsDocsLinkEnd: '</a>'.html_safe, keyboardStart: '<kbd>'.html_safe, keyboardEnd: '</kbd>'.html_safe }
- - else
- = html_escape(s_('MarkdownToolbar|Supports %{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd}')) % { markdownDocsLinkStart: markdownLinkStart, markdownDocsLinkEnd: '</a>'.html_safe }
+.comment-toolbar.gl-px-2.gl-display-flex.gl-justify-content-end.gl-rounded-bottom-left-base.gl-rounded-bottom-right-base.clearfix
+ .content-editor-switcher.gl-display-inline-flex.gl-align-items-center
+ = render Pajamas::ButtonComponent.new(category: :tertiary, icon: 'markdown-mark', size: :small, href: help_page_path('user/markdown'), target: '_blank', button_options: { class: 'gl-px-3!' })
- if supports_file_upload
%span.uploading-container.gl-line-height-32.gl-font-sm
%span.uploading-progress-container.hide
diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml
index 72709b3ed2f..2388bf2f0be 100644
--- a/app/views/shared/projects/_search_form.html.haml
+++ b/app/views/shared/projects/_search_form.html.haml
@@ -51,5 +51,5 @@
.gl-display-flex.gl-w-full.gl-md-w-auto{ class: 'gl-m-0!' }
.js-namespace-select{ data: { field_name: 'namespace_id', selected_id: namespace&.id, selected_text: selected_text, update_location: 'true' } }
- = link_to new_project_path, class: 'gl-button btn btn-confirm gl-display-inline gl-mb-0!' do
+ = link_button_to new_project_path, class: 'gl-display-inline gl-mb-0!', variant: :confirm do
= _('New Project')
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index 7eafd6ae092..a0e55cd5723 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -3,7 +3,7 @@
.js-vue-webhook-form{ data: webhook_form_data(hook) }
.form-group
= form.label :token, s_('Webhooks|Secret token'), class: 'label-bold'
- = form.password_field :token, value: hook.masked_token, autocomplete: 'new-password', class: 'form-control gl-form-input'
+ = form.password_field :token, value: hook.masked_token, autocomplete: 'new-password', class: 'form-control gl-form-input gl-max-w-48'
%p.form-text.text-muted
- code_start = '<code>'.html_safe
- code_end = '</code>'.html_safe
@@ -11,59 +11,66 @@
.form-group
= form.label :url, s_('Webhooks|Trigger'), class: 'label-bold'
%ul.list-unstyled
- %li.gl-pb-5
+ %li.gl-pb-3
.js-vue-push-events{ data: { push_events: hook.push_events.to_s, strategy: hook.branch_filter_strategy, is_new_hook: hook.new_record?.to_s, push_events_branch_filter: hook.push_events_branch_filter } }
- %li.gl-pb-5
+ %li.gl-pb-3
= form.gitlab_ui_checkbox_component :tag_push_events,
integration_webhook_event_human_name(:tag_push_events),
help_text: s_('Webhooks|A new tag is pushed to the repository.')
- %li.gl-pb-5
+ %li.gl-pb-3
= form.gitlab_ui_checkbox_component :note_events,
integration_webhook_event_human_name(:note_events),
help_text: s_('Webhooks|A comment is added to an issue or merge request.')
- %li.gl-pb-5
+ %li.gl-pb-3
= form.gitlab_ui_checkbox_component :confidential_note_events,
integration_webhook_event_human_name(:confidential_note_events),
help_text: s_('Webhooks|A comment is added to a confidential issue.')
- %li.gl-pb-5
+ %li.gl-pb-3
= form.gitlab_ui_checkbox_component :issues_events,
integration_webhook_event_human_name(:issues_events),
help_text: s_('Webhooks|An issue is created, updated, closed, or reopened.')
- %li.gl-pb-5
+ %li.gl-pb-3
= form.gitlab_ui_checkbox_component :confidential_issues_events,
integration_webhook_event_human_name(:confidential_issues_events),
help_text: s_('Webhooks|A confidential issue is created, updated, closed, or reopened.')
- if @group
= render_if_exists 'groups/hooks/member_events', form: form
= render_if_exists 'groups/hooks/subgroup_events', form: form
- %li.gl-pb-5
+ %li.gl-pb-3
= form.gitlab_ui_checkbox_component :merge_requests_events,
integration_webhook_event_human_name(:merge_requests_events),
help_text: s_('Webhooks|A merge request is created, updated, or merged.')
- %li.gl-pb-5
+ %li.gl-pb-3
= form.gitlab_ui_checkbox_component :job_events,
integration_webhook_event_human_name(:job_events),
help_text: s_("Webhooks|A job's status changes.")
- %li.gl-pb-5
+ %li.gl-pb-3
= form.gitlab_ui_checkbox_component :pipeline_events,
integration_webhook_event_human_name(:pipeline_events),
help_text: s_("Webhooks|A pipeline's status changes.")
- %li.gl-pb-5
+ %li.gl-pb-3
= form.gitlab_ui_checkbox_component :wiki_page_events,
integration_webhook_event_human_name(:wiki_page_events),
help_text: s_('Webhooks|A wiki page is created or updated.')
- %li.gl-pb-5
+ %li.gl-pb-3
= form.gitlab_ui_checkbox_component :deployment_events,
integration_webhook_event_human_name(:deployment_events),
help_text: s_('Webhooks|A deployment starts, finishes, fails, or is canceled.')
- %li.gl-pb-5
+ %li.gl-pb-3
= form.gitlab_ui_checkbox_component :feature_flag_events,
integration_webhook_event_human_name(:feature_flag_events),
help_text: s_('Webhooks|A feature flag is turned on or off.')
- %li.gl-pb-5
+ %li.gl-pb-3
= form.gitlab_ui_checkbox_component :releases_events,
integration_webhook_event_human_name(:releases_events),
help_text: s_('Webhooks|A release is created or updated.')
+ - if Feature.enabled?(:emoji_webhooks, hook.parent)
+ %li.gl-pb-5
+ - emoji_help_link = link_to s_('Which emoji events trigger webhooks'), help_page_path('user/project/integrations/webhook_events.md', anchor: 'emoji-events')
+ = form.gitlab_ui_checkbox_component :emoji_events,
+ integration_webhook_event_human_name(:emoji_events),
+ help_text: s_('Webhooks|An emoji is awarded or revoked. %{help_link}?').html_safe % { help_link: emoji_help_link }
+
.form-group
= form.label :enable_ssl_verification, s_('Webhooks|SSL verification'), class: 'label-bold checkbox'
%ul.list-unstyled
diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml
index 155a7b1827f..50ce6552616 100644
--- a/app/views/shared/web_hooks/_hook.html.haml
+++ b/app/views/shared/web_hooks/_hook.html.haml
@@ -1,10 +1,10 @@
- sslStatus = hook.enable_ssl_verification ? _('enabled') : _('disabled')
- sslBadgeText = _('SSL Verification:') + ' ' + sslStatus
-%li
- .row
- .col-md-8.col-lg-7
- %strong.light-header
+%li.label-list-item
+ .gl-display-flex.lgl-align-items-center.row.gl-mx-n1
+ .col-md-8.col-lg-7.gl-px-3
+ .light-header.gl-mb-2
= hook.url
- if hook.rate_limited?
= gl_badge_tag(_('Disabled'), variant: :danger, size: :sm)
@@ -19,7 +19,7 @@
= gl_badge_tag(integration_webhook_event_human_name(trigger), size: :sm)
= gl_badge_tag(sslBadgeText, size: :sm)
- .col-md-4.col-lg-5.gl-mt-2.gl-display-flex.gl-md-justify-content-end.gl-align-items-baseline.gl-gap-3
+ .col-md-4.col-lg-5.gl-mt-2.gl-px-3.gl-gap-3.gl-display-flex.gl-md-justify-content-end.gl-align-items-baseline
= render 'shared/web_hooks/test_button', hook: hook, size: 'small'
= render Pajamas::ButtonComponent.new(href: edit_hook_path(hook), size: :small) do
= _('Edit')
diff --git a/app/views/shared/web_hooks/_index.html.haml b/app/views/shared/web_hooks/_index.html.haml
index 8a81e697a59..0ea6a0307ba 100644
--- a/app/views/shared/web_hooks/_index.html.haml
+++ b/app/views/shared/web_hooks/_index.html.haml
@@ -1,13 +1,24 @@
-%hr
-= render Pajamas::CardComponent.new(card_options: { id: 'webhooks-index' }, body_options: { class: 'gl-py-0'}) do |c|
+= render Pajamas::CardComponent.new(card_options: { id: 'webhooks-index', class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header'}, body_options: { class: 'gl-new-card-body'}) do |c|
- c.with_header do
- = hook_class.underscore.humanize.titleize.pluralize
- (#{hooks.size})
+ .gl-new-card-title-wrapper
+ %h3.gl-new-card-title
+ = hook_class.underscore.humanize.titleize.pluralize
+ %span.gl-new-card-count
+ = sprite_icon('hook', css_class: 'gl-mr-2')
+ #{hooks.size}
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-toggle-button js-toggle-content' }) do
+ = _('Add new webhook')
- c.with_body do
- - if hooks.any?
- %ul.content-list
- - hooks.each do |hook|
- = render 'shared/web_hooks/hook', hook: hook
- - else
- %p.text-center.gl-mt-3.gl-mb-3
- = _('No webhooks enabled. Select trigger events above.')
+ .gl-new-card-content
+ = gitlab_ui_form_for @hook, as: :hook, url: url, html: { class: 'js-webhook-form gl-new-card-add-form gl-mb-3 gl-display-none js-toggle-content' } do |f|
+ = render partial: partial, locals: { form: f, hook: @hook }
+ = f.submit _('Add webhook'), pajamas_button: true, data: { qa_selector: "create_webhook_button" }
+ = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'js-webhook-edit-close gl-ml-2 js-toggle-button' }) do
+ = _('Cancel')
+ - if hooks.any?
+ %ul.content-list{ class: 'gl-my-n3!' }
+ - hooks.each do |hook|
+ = render 'shared/web_hooks/hook', hook: hook
+ - else
+ %p.gl-new-card-empty.gl-text-center
+ = _('No webhooks enabled. Select trigger events above.')
diff --git a/app/views/shared/web_hooks/_title_and_docs.html.haml b/app/views/shared/web_hooks/_title_and_docs.html.haml
index c220b46f70f..ae32dcea7cb 100644
--- a/app/views/shared/web_hooks/_title_and_docs.html.haml
+++ b/app/views/shared/web_hooks/_title_and_docs.html.haml
@@ -1,10 +1,12 @@
- webhooks_link_start = '<a href="%{url}">'.html_safe % { url: help_page_path(hook.help_path) }
-%h4.gl-mt-0
- = page_title
+.settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = page_title
- if @project
- integrations_link_start = '<a href="%{url}">'.html_safe % { url: scoped_integrations_path(project: @project) }
- %p= _("%{webhooks_link_start}%{webhook_type}%{link_end} enable you to send notifications to web applications in response to events in a group or project. We recommend using an %{integrations_link_start}integration%{link_end} in preference to a webhook.").html_safe % { webhooks_link_start: webhooks_link_start, webhook_type: hook.pluralized_name, integrations_link_start: integrations_link_start, link_end: '</a>'.html_safe }
+ %p.gl-text-secondary= _("%{webhooks_link_start}%{webhook_type}%{link_end} enable you to send notifications to web applications in response to events in a group or project. We recommend using an %{integrations_link_start}integration%{link_end} in preference to a webhook.").html_safe % { webhooks_link_start: webhooks_link_start, webhook_type: hook.pluralized_name, integrations_link_start: integrations_link_start, link_end: '</a>'.html_safe }
- else
- %p= _("%{webhooks_link_start}%{webhook_type}%{link_end} enable you to send notifications to web applications in response to events in a group or project.").html_safe % { webhooks_link_start: webhooks_link_start, webhook_type: hook.pluralized_name, link_end: '</a>'.html_safe }
+ %p.gl-text-secondary= _("%{webhooks_link_start}%{webhook_type}%{link_end} enable you to send notifications to web applications in response to events in a group or project.").html_safe % { webhooks_link_start: webhooks_link_start, webhook_type: hook.pluralized_name, link_end: '</a>'.html_safe }
diff --git a/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml b/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml
index cbbb2f51fd5..1580fc0bd6d 100644
--- a/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml
+++ b/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml
@@ -10,4 +10,4 @@
= succeed '.' do
= link_to _('Learn more'), help_page_path('user/project/integrations/webhooks', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer'
- c.with_actions do
- = link_to s_('Webhooks|Go to webhooks'), project_hooks_path(@project, anchor: 'webhooks-index'), class: 'btn gl-alert-action btn-confirm gl-button'
+ = link_button_to s_('Webhooks|Go to webhooks'), project_hooks_path(@project, anchor: 'webhooks-index'), class: 'gl-alert-action', variant: :confirm
diff --git a/app/views/shared/wikis/_main_links.html.haml b/app/views/shared/wikis/_main_links.html.haml
index c1fd8c48c60..41831c95198 100644
--- a/app/views/shared/wikis/_main_links.html.haml
+++ b/app/views/shared/wikis/_main_links.html.haml
@@ -1,6 +1,6 @@
- if @page&.persisted?
- = link_to wiki_page_path(@wiki, @page, action: :history), class: "btn gl-button btn-default", role: "button", data: { qa_selector: 'page_history_button' } do
+ = link_button_to wiki_page_path(@wiki, @page, action: :history), role: "button", data: { qa_selector: 'page_history_button' } do
= s_("Wiki|Page history")
- if can?(current_user, :create_wiki, @wiki.container)
- = link_to wiki_path(@wiki, action: :new), class: "btn gl-button btn-confirm-secondary", role: "button", data: { qa_selector: 'new_page_button' } do
+ = link_button_to wiki_path(@wiki, action: :new), role: "button", data: { qa_selector: 'new_page_button' }, variant: :confirm, category: :secondary do
= s_("Wiki|New page")
diff --git a/app/views/shared/wikis/_sidebar.html.haml b/app/views/shared/wikis/_sidebar.html.haml
index 8b8c981da96..a34827602ab 100644
--- a/app/views/shared/wikis/_sidebar.html.haml
+++ b/app/views/shared/wikis/_sidebar.html.haml
@@ -32,5 +32,5 @@
= render partial: entry.to_partial_path, object: entry, locals: { context: 'sidebar' }
.block.w-100
- if @sidebar_limited
- = link_to wiki_path(@wiki, action: :pages), class: 'btn gl-button btn-block', data: { qa_selector: 'view_all_pages_button' } do
+ = link_button_to wiki_path(@wiki, action: :pages), data: { qa_selector: 'view_all_pages_button' }, block: true do
= s_("Wiki|View All Pages")
diff --git a/app/views/shared/wikis/diff.html.haml b/app/views/shared/wikis/diff.html.haml
index ee6c7f307a7..67772ec40c1 100644
--- a/app/views/shared/wikis/diff.html.haml
+++ b/app/views/shared/wikis/diff.html.haml
@@ -12,7 +12,7 @@
= _('Changes')
.nav-controls.pb-md-3.pb-lg-0
- = link_to wiki_page_path(@wiki, @page, action: :history), class: 'btn gl-button', role: 'button', data: { qa_selector: 'page_history_button' } do
+ = link_button_to wiki_page_path(@wiki, @page, action: :history), role: 'button', data: { qa_selector: 'page_history_button' } do
= s_('Wiki|Page history')
.page-content-header
diff --git a/app/views/shared/wikis/pages.html.haml b/app/views/shared/wikis/pages.html.haml
index f35649d031c..4656bb8d453 100644
--- a/app/views/shared/wikis/pages.html.haml
+++ b/app/views/shared/wikis/pages.html.haml
@@ -8,8 +8,7 @@
= s_("Wiki|Wiki Pages")
.nav-controls.pb-md-3.pb-lg-0
- = link_to wiki_path(@wiki, action: :git_access), class: 'btn gl-button' do
- = sprite_icon('download')
+ = link_button_to wiki_path(@wiki, action: :git_access), icon: 'download' do
= _("Clone repository")
.dropdown.inline.wiki-sort-dropdown
diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml
index 3841113231c..28699ca27f3 100644
--- a/app/views/shared/wikis/show.html.haml
+++ b/app/views/shared/wikis/show.html.haml
@@ -14,18 +14,23 @@
= render 'shared/wikis/main_links'
- if @page.historical?
- .warning_message
- = s_("WikiHistoricalPage|This is an old version of this page.")
- - most_recent_link = link_to s_("WikiHistoricalPage|most recent version"), wiki_page_path(@wiki, @page)
- - history_link = link_to s_("WikiHistoricalPage|history"), wiki_page_path(@wiki, @page, action: :history)
- = (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe
+ = render Pajamas::AlertComponent.new(variant: :warning,
+ dismissible: false) do |c|
+ - c.with_body do
+ = s_("WikiHistoricalPage|This is an old version of this page.")
+ - c.with_actions do
+ .gl-display-flex.gl-gap-3
+ = render Pajamas::ButtonComponent.new(category: :primary, variant: :confirm, href: wiki_page_path(@wiki, @page)) do
+ = s_('WikiHistoricalPage|Go to most recent version')
+ = render Pajamas::ButtonComponent.new(href: wiki_page_path(@wiki, @page, action: :history)) do
+ = s_('WikiHistoricalPage|Browse history')
.gl-mt-5.gl-mb-3
.gl-display-flex.gl-justify-content-space-between
%h2.gl-mt-0.gl-mb-5{ data: { qa_selector: 'wiki_page_title', testid: 'wiki_page_title' } }= @page.human_title
%div
- if can?(current_user, :create_wiki, @wiki.container) && @page.latest? && @valid_encoding
- = link_to sprite_icon('pencil', css_class: 'gl-icon'), wiki_page_path(@wiki, @page, action: :edit), title: 'Edit', role: "button", class: 'btn gl-button btn-icon btn-default js-wiki-edit', data: { qa_selector: 'edit_page_button', testid: 'wiki_edit_button' }
+ = render Pajamas::ButtonComponent.new(href: wiki_page_path(@wiki, @page, action: :edit), icon: 'pencil', button_options: { class: 'js-wiki-edit', title: "Edit", data: { qa_selector: 'edit_page_button', testid: 'wiki_edit_button' }})
.js-async-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki-page-content', tracking_context: wiki_page_tracking_context(@page).to_json, get_wiki_content_url: wiki_page_render_api_endpoint(@page) } }
diff --git a/app/views/users/_follow_user.html.haml b/app/views/users/_follow_user.html.haml
new file mode 100644
index 00000000000..3ee8c81496c
--- /dev/null
+++ b/app/views/users/_follow_user.html.haml
@@ -0,0 +1,11 @@
+- link_classes = "flex-grow-1 gl-display-inline-block"
+
+- if current_user&.following_users_allowed?(@user)
+ - if current_user.following?(@user)
+ = form_tag user_unfollow_path(@user, :json), class: link_classes do
+ = render Pajamas::ButtonComponent.new(type: :submit, button_options: { class: 'gl-w-full', data: { track_action: 'click_button', track_label: 'unfollow_from_profile' } }) do
+ = _('Unfollow')
+ - else
+ = form_tag user_follow_path(@user, :json), class: link_classes do
+ = render Pajamas::ButtonComponent.new(variant: :confirm, type: :submit, button_options: { class: 'gl-w-full', data: { qa_selector: 'follow_user_link', track_action: 'click_button', track_label: 'follow_from_profile' } }) do
+ = _('Follow')
diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml
index ce82a5e1614..0b76ed6c086 100644
--- a/app/views/users/_overview.html.haml
+++ b/app/views/users/_overview.html.haml
@@ -13,12 +13,10 @@
.col-12.col-md-10.col-lg-8.gl-my-6
.gl-display-flex
%ol.breadcrumb.gl-breadcrumb-list.gl-mb-4
- %li.breadcrumb-item.gl-breadcrumb-item
+ %li.gl-breadcrumb-item
= link_to project_path(@user.user_project) do
= @user.username
- %span.gl-breadcrumb-separator
- = sprite_icon("chevron-right", size: 16)
- %li.breadcrumb-item.gl-breadcrumb-item
+ %li.gl-breadcrumb-item
= link_to @user.user_readme.path, @user.user_project.readme_url
- if current_user == @user
.gl-ml-auto
diff --git a/app/views/users/_profile_basic_info.html.haml b/app/views/users/_profile_basic_info.html.haml
index 7c50031598c..6de9e80008e 100644
--- a/app/views/users/_profile_basic_info.html.haml
+++ b/app/views/users/_profile_basic_info.html.haml
@@ -2,8 +2,9 @@
= render 'middle_dot_divider', stacking: true do
@#{@user.username}
- if can?(current_user, :read_user_profile, @user)
- = render 'middle_dot_divider', stacking: true do
- = s_('UserProfile|User ID: %{id}') % { id: @user.id }
- = clipboard_button(title: s_('UserProfile|Copy user ID'), text: @user.id)
+ - unless Feature.enabled?(:user_profile_overflow_menu_vue)
+ = render 'middle_dot_divider', stacking: true do
+ = s_('UserProfile|User ID: %{id}') % { id: @user.id }
+ = clipboard_button(title: s_('UserProfile|Copy user ID'), text: @user.id)
= render 'middle_dot_divider', stacking: true do
= s_('Member since %{date}') % { date: l(@user.created_at.to_date, format: :long) }
diff --git a/app/views/users/_view_gpg_keys.html.haml b/app/views/users/_view_gpg_keys.html.haml
new file mode 100644
index 00000000000..aa0f69ffe3c
--- /dev/null
+++ b/app/views/users/_view_gpg_keys.html.haml
@@ -0,0 +1,5 @@
+- verified_gpg_keys = @user.gpg_keys.select(&:verified?)
+- if verified_gpg_keys.any?
+ = render Pajamas::ButtonComponent.new(href: user_gpg_keys_path,
+ icon: 'key',
+ button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: n_('View public GPG key', 'View public GPG keys', verified_gpg_keys.length), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }})
diff --git a/app/views/users/_view_user_in_admin_area.html.haml b/app/views/users/_view_user_in_admin_area.html.haml
new file mode 100644
index 00000000000..b13f22956f6
--- /dev/null
+++ b/app/views/users/_view_user_in_admin_area.html.haml
@@ -0,0 +1,4 @@
+- if current_user && current_user.admin?
+ = render Pajamas::ButtonComponent.new(href: [:admin, @user],
+ icon: 'user',
+ button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|View user in admin area'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } })
diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml
index 3571031fbfa..e98dd87a307 100644
--- a/app/views/users/calendar_activities.html.haml
+++ b/app/views/users/calendar_activities.html.haml
@@ -1,5 +1,5 @@
%h4.prepend-top-20
- = html_escape(_("Contributions for %{calendar_date}")) % { calendar_date: tag.strong(@calendar_date.to_s(:medium)) }
+ = html_escape(_("Contributions for %{calendar_date}")) % { calendar_date: tag.strong(@calendar_date.to_fs(:medium)) }
- if @events.any?
%ul.bordered-list
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 4113a276416..380d6aacb84 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -5,7 +5,6 @@
- page_description @user.bio unless @user.blocked? || !@user.confirmed?
- page_itemtype 'http://schema.org/Person'
- add_page_specific_style 'page_bundles/profile'
-- link_classes = "flex-grow-1 mx-1 "
- if show_super_sidebar?
- @left_sidebar = true
- @force_desktop_expanded_sidebar = true
@@ -17,35 +16,32 @@
.user-profile
.cover-block.user-cover-block.gl-border-t.gl-border-b.gl-mt-n1
%div{ class: container_class }
- = render layout: 'users/cover_controls' do
- - if @user == current_user
- = render Pajamas::ButtonComponent.new(href: profile_path,
- icon: 'pencil',
- button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile', data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }})
- - elsif current_user
- #js-report-abuse{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: @user.id, reported_from_url: user_url(@user) } }
- - verified_gpg_keys = @user.gpg_keys.select(&:verified?)
- - if verified_gpg_keys.any?
- = render Pajamas::ButtonComponent.new(href: user_gpg_keys_path,
- icon: 'key',
- button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: n_('View public GPG key', 'View public GPG keys', verified_gpg_keys.length), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }})
- - if can?(current_user, :read_user_profile, @user)
- = render Pajamas::ButtonComponent.new(href: user_path(@user, rss_url_options),
- icon: 'rss',
- button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }})
- - if current_user && current_user.admin?
- = render Pajamas::ButtonComponent.new(href: [:admin, @user],
- icon: 'user',
- button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|View user in admin area'), data: {toggle: 'tooltip', placement: 'bottom', container: 'body'}})
- - if current_user && current_user.following_users_allowed?(@user)
- - if current_user.following?(@user)
- = form_tag user_unfollow_path(@user, :json), class: link_classes + 'gl-display-inline-block' do
- = render Pajamas::ButtonComponent.new(type: :submit, button_options: { class: 'gl-w-full', data: { track_action: 'click_button', track_label: 'unfollow_from_profile' } }) do
- = _('Unfollow')
- - else
- = form_tag user_follow_path(@user, :json), class: link_classes + 'gl-display-inline-block' do
- = render Pajamas::ButtonComponent.new(variant: :confirm, type: :submit, button_options: { class: 'gl-w-full', data: { qa_selector: 'follow_user_link', track_action: 'click_button', track_label: 'follow_from_profile' } }) do
- = _('Follow')
+ - if Feature.enabled?(:user_profile_overflow_menu_vue)
+ .cover-controls.d-flex.px-2.pb-4.d-sm-block.p-sm-0
+ = render 'users/follow_user'
+ -# The following edit button is mutually exclusive to the follow user button, they won't be shown together
+ - if @user == current_user
+ = render Pajamas::ButtonComponent.new(href: profile_path,
+ button_options: { class: 'gl-flex-grow-1', title: s_('UserProfile|Edit profile') }) do
+ = s_("UserProfile|Edit profile")
+ = render 'users/view_gpg_keys'
+ = render 'users/view_user_in_admin_area'
+ .js-user-profile-actions{ data: { user_id: @user.id } }
+ - else
+ = render layout: 'users/cover_controls' do
+ - if @user == current_user
+ = render Pajamas::ButtonComponent.new(href: profile_path,
+ icon: 'pencil',
+ button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile', data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }})
+ - elsif current_user
+ #js-report-abuse{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: @user.id, reported_from_url: user_url(@user) } }
+ = render 'users/view_gpg_keys'
+ - if can?(current_user, :read_user_profile, @user)
+ = render Pajamas::ButtonComponent.new(href: user_path(@user, rss_url_options),
+ icon: 'rss',
+ button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }})
+ = render 'users/view_user_in_admin_area'
+ = render 'users/follow_user'
.profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?), ('gl-mb-4!' if show_super_sidebar?)] }
.gl-display-inline-block.gl-mx-8.gl-vertical-align-top
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index f8aa06943ee..6f6fd9ddb65 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -318,15 +318,6 @@
:weight: 1
:idempotent: false
:tags: []
-- :name: cronjob:clusters_integrations_check_prometheus_health
- :worker_name: Clusters::Integrations::CheckPrometheusHealthWorker
- :feature_category: :incident_management
- :has_external_dependencies: true
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent: true
- :tags: []
- :name: cronjob:container_expiration_policy
:worker_name: ContainerExpirationPolicyWorker
:feature_category: :container_registry
@@ -561,15 +552,6 @@
:weight: 1
:idempotent: false
:tags: []
-- :name: cronjob:metrics_dashboard_schedule_annotations_prune
- :worker_name: Metrics::Dashboard::ScheduleAnnotationsPruneWorker
- :feature_category: :metrics
- :has_external_dependencies: false
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent: true
- :tags: []
- :name: cronjob:metrics_global_metrics_update
:worker_name: Metrics::GlobalMetricsUpdateWorker
:feature_category: :metrics
@@ -1740,15 +1722,6 @@
:weight: 1
:idempotent: true
:tags: []
-- :name: package_repositories:packages_debian_process_changes
- :worker_name: Packages::Debian::ProcessChangesWorker
- :feature_category: :package_registry
- :has_external_dependencies: false
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent: true
- :tags: []
- :name: package_repositories:packages_debian_process_package_file
:worker_name: Packages::Debian::ProcessPackageFileWorker
:feature_category: :package_registry
@@ -2001,6 +1974,15 @@
:weight: 3
:idempotent: false
:tags: []
+- :name: pipeline_default:ci_pipeline_cleanup_ref
+ :worker_name: Ci::PipelineCleanupRefWorker
+ :feature_category: :continuous_integration
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 3
+ :idempotent: true
+ :tags: []
- :name: pipeline_default:ci_retry_pipeline
:worker_name: Ci::RetryPipelineWorker
:feature_category: :continuous_integration
@@ -2424,6 +2406,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: bulk_imports_finish_batched_pipeline
+ :worker_name: BulkImports::FinishBatchedPipelineWorker
+ :feature_category: :importers
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: bulk_imports_finish_batched_relation_export
:worker_name: BulkImports::FinishBatchedRelationExportWorker
:feature_category: :importers
@@ -2442,6 +2433,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: bulk_imports_pipeline_batch
+ :worker_name: BulkImports::PipelineBatchWorker
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: false
+ :tags: []
- :name: bulk_imports_relation_batch_export
:worker_name: BulkImports::RelationBatchExportWorker
:feature_category: :importers
@@ -2856,6 +2856,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: integrations_group_mention
+ :worker_name: Integrations::GroupMentionWorker
+ :feature_category: :integrations
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: integrations_irker
:worker_name: Integrations::IrkerWorker
:feature_category: :integrations
@@ -2973,6 +2982,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: merge_requests_cleanup_ref
+ :worker_name: MergeRequests::CleanupRefWorker
+ :feature_category: :code_review_workflow
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: merge_requests_close_issue
:worker_name: MergeRequests::CloseIssueWorker
:feature_category: :code_review_workflow
@@ -3072,24 +3090,6 @@
:weight: 1
:idempotent: true
:tags: []
-- :name: metrics_dashboard_prune_old_annotations
- :worker_name: Metrics::Dashboard::PruneOldAnnotationsWorker
- :feature_category: :metrics
- :has_external_dependencies: false
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent: true
- :tags: []
-- :name: metrics_dashboard_sync_dashboards
- :worker_name: Metrics::Dashboard::SyncDashboardsWorker
- :feature_category: :metrics
- :has_external_dependencies: false
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent: true
- :tags: []
- :name: migrate_external_diffs
:worker_name: MigrateExternalDiffsWorker
:feature_category: :code_review_workflow
@@ -3486,6 +3486,15 @@
:weight: 2
:idempotent: false
:tags: []
+- :name: redis_migration
+ :worker_name: RedisMigrationWorker
+ :feature_category: :redis
+ :has_external_dependencies: false
+ :urgency: :throttled
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: releases_create_evidence
:worker_name: Releases::CreateEvidenceWorker
:feature_category: :release_evidence
diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb
index 530419dac26..44759916f99 100644
--- a/app/workers/bulk_imports/export_request_worker.rb
+++ b/app/workers/bulk_imports/export_request_worker.rb
@@ -15,35 +15,40 @@ module BulkImports
end
def perform(entity_id)
- entity = BulkImports::Entity.find(entity_id)
+ @entity = BulkImports::Entity.find(entity_id)
- entity.update!(source_xid: entity_source_xid(entity)) if entity.source_xid.nil?
-
- request_export(entity)
+ set_source_xid
+ request_export
BulkImports::EntityWorker.perform_async(entity_id)
end
def perform_failure(exception, entity_id)
- entity = BulkImports::Entity.find(entity_id)
+ @entity = BulkImports::Entity.find(entity_id)
- log_and_fail(exception, entity)
+ log_and_fail(exception)
end
private
- def request_export(entity)
- http_client(entity).post(entity.export_relations_url_path)
+ attr_reader :entity
+
+ def set_source_xid
+ entity.update!(source_xid: entity_source_xid) if entity.source_xid.nil?
+ end
+
+ def request_export
+ http_client.post(export_url)
end
- def http_client(entity)
+ def http_client
@client ||= Clients::HTTP.new(
url: entity.bulk_import.configuration.url,
token: entity.bulk_import.configuration.access_token
)
end
- def failure_attributes(exception, entity)
+ def failure_attributes(exception)
{
bulk_import_entity_id: entity.id,
pipeline_class: 'ExportRequestWorker',
@@ -53,23 +58,20 @@ module BulkImports
}
end
- def graphql_client(entity)
+ def graphql_client
@graphql_client ||= BulkImports::Clients::Graphql.new(
url: entity.bulk_import.configuration.url,
token: entity.bulk_import.configuration.access_token
)
end
- def entity_source_xid(entity)
- query = entity_query(entity)
- client = graphql_client(entity)
-
- response = client.execute(
- client.parse(query.to_s),
+ def entity_source_xid
+ response = graphql_client.execute(
+ graphql_client.parse(entity_query.to_s),
{ full_path: entity.source_full_path }
).original_hash
- ::GlobalID.parse(response.dig(*query.data_path, 'id')).model_id
+ ::GlobalID.parse(response.dig(*entity_query.data_path, 'id')).model_id
rescue StandardError => e
log_exception(e,
{
@@ -86,12 +88,12 @@ module BulkImports
nil
end
- def entity_query(entity)
- if entity.group?
- BulkImports::Groups::Graphql::GetGroupQuery.new(context: nil)
- else
- BulkImports::Projects::Graphql::GetProjectQuery.new(context: nil)
- end
+ def entity_query
+ @entity_query ||= if entity.group?
+ BulkImports::Groups::Graphql::GetGroupQuery.new(context: nil)
+ else
+ BulkImports::Projects::Graphql::GetProjectQuery.new(context: nil)
+ end
end
def logger
@@ -104,7 +106,7 @@ module BulkImports
logger.error(structured_payload(payload))
end
- def log_and_fail(exception, entity)
+ def log_and_fail(exception)
log_exception(exception,
{
bulk_import_entity_id: entity.id,
@@ -117,9 +119,13 @@ module BulkImports
}
)
- BulkImports::Failure.create(failure_attributes(exception, entity))
+ BulkImports::Failure.create(failure_attributes(exception))
entity.fail_op!
end
+
+ def export_url
+ entity.export_relations_url_path(batched: Feature.enabled?(:bulk_imports_batched_import_export))
+ end
end
end
diff --git a/app/workers/bulk_imports/finish_batched_pipeline_worker.rb b/app/workers/bulk_imports/finish_batched_pipeline_worker.rb
new file mode 100644
index 00000000000..4200d0e4a0f
--- /dev/null
+++ b/app/workers/bulk_imports/finish_batched_pipeline_worker.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class FinishBatchedPipelineWorker
+ include ApplicationWorker
+ include ExceptionBacktrace
+
+ REQUEUE_DELAY = 5.seconds
+
+ idempotent!
+ deduplicate :until_executing
+ data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency
+ feature_category :importers
+
+ def perform(pipeline_tracker_id)
+ @tracker = Tracker.find(pipeline_tracker_id)
+
+ return unless tracker.batched?
+ return unless tracker.started?
+ return re_enqueue if import_in_progress?
+
+ if tracker.stale?
+ tracker.batches.map(&:fail_op!)
+ tracker.fail_op!
+ else
+ tracker.finish!
+ end
+
+ ensure
+ ::BulkImports::EntityWorker.perform_async(tracker.entity.id, tracker.stage)
+ end
+
+ private
+
+ attr_reader :tracker
+
+ def re_enqueue
+ self.class.perform_in(REQUEUE_DELAY, tracker.id)
+ end
+
+ def import_in_progress?
+ tracker.batches.any?(&:started?)
+ end
+ end
+end
diff --git a/app/workers/bulk_imports/finish_batched_relation_export_worker.rb b/app/workers/bulk_imports/finish_batched_relation_export_worker.rb
index aa7bbffa732..92a33a971e7 100644
--- a/app/workers/bulk_imports/finish_batched_relation_export_worker.rb
+++ b/app/workers/bulk_imports/finish_batched_relation_export_worker.rb
@@ -5,7 +5,7 @@ module BulkImports
include ApplicationWorker
idempotent!
- data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency
+ data_consistency :sticky
feature_category :importers
REENQUEUE_DELAY = 5.seconds
diff --git a/app/workers/bulk_imports/pipeline_batch_worker.rb b/app/workers/bulk_imports/pipeline_batch_worker.rb
new file mode 100644
index 00000000000..378eff99b52
--- /dev/null
+++ b/app/workers/bulk_imports/pipeline_batch_worker.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class PipelineBatchWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+ include ExclusiveLeaseGuard
+
+ data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency
+ feature_category :importers
+ sidekiq_options retry: false, dead: false
+ worker_has_external_dependencies!
+
+ def perform(batch_id)
+ @batch = ::BulkImports::BatchTracker.find(batch_id)
+ @tracker = @batch.tracker
+
+ try_obtain_lease { run }
+ ensure
+ ::BulkImports::FinishBatchedPipelineWorker.perform_async(tracker.id)
+ end
+
+ private
+
+ attr_reader :batch, :tracker
+
+ def run
+ return batch.skip! if tracker.failed? || tracker.finished?
+
+ batch.start!
+ tracker.pipeline_class.new(context).run
+ batch.finish!
+ rescue BulkImports::RetryPipelineError => e
+ retry_batch(e)
+ rescue StandardError => e
+ fail_batch(e)
+ end
+
+ def fail_batch(exception)
+ batch.fail_op!
+
+ Gitlab::ErrorTracking.track_exception(
+ exception,
+ batch_id: batch.id,
+ tracker_id: tracker.id,
+ pipeline_class: tracker.pipeline_name,
+ pipeline_step: 'pipeline_batch_worker_run'
+ )
+
+ BulkImports::Failure.create(
+ bulk_import_entity_id: batch.tracker.entity.id,
+ pipeline_class: tracker.pipeline_name,
+ pipeline_step: 'pipeline_batch_worker_run',
+ exception_class: exception.class.to_s,
+ exception_message: exception.message.truncate(255),
+ correlation_id_value: Labkit::Correlation::CorrelationId.current_or_new_id
+ )
+ end
+
+ def context
+ @context ||= ::BulkImports::Pipeline::Context.new(tracker, batch_number: batch.batch_number)
+ end
+
+ def retry_batch(exception)
+ batch.retry!
+
+ re_enqueue(exception.retry_delay)
+ end
+
+ def lease_timeout
+ 30
+ end
+
+ def lease_key
+ "gitlab:bulk_imports:pipeline_batch_worker:#{batch.id}"
+ end
+
+ def re_enqueue(delay = FILE_EXTRACTION_PIPELINE_PERFORM_DELAY)
+ self.class.perform_in(delay, batch.id)
+ end
+ end
+end
diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb
index f03e0bc0656..e0db18cb987 100644
--- a/app/workers/bulk_imports/pipeline_worker.rb
+++ b/app/workers/bulk_imports/pipeline_worker.rb
@@ -31,7 +31,6 @@ module BulkImports
fail_tracker(StandardError.new(message)) unless pipeline_tracker.finished? || pipeline_tracker.skipped?
end
end
-
ensure
::BulkImports::EntityWorker.perform_async(entity_id, stage)
end
@@ -49,9 +48,17 @@ module BulkImports
return re_enqueue if export_empty? || export_started?
- pipeline_tracker.update!(status_event: 'start', jid: jid)
- pipeline_tracker.pipeline_class.new(context).run
- pipeline_tracker.finish!
+ if file_extraction_pipeline? && export_status.batched?
+ pipeline_tracker.update!(status_event: 'start', jid: jid, batched: true)
+
+ return pipeline_tracker.finish! if export_status.batches_count < 1
+
+ enqueue_batches
+ else
+ pipeline_tracker.update!(status_event: 'start', jid: jid)
+ pipeline_tracker.pipeline_class.new(context).run
+ pipeline_tracker.finish!
+ end
rescue BulkImports::RetryPipelineError => e
retry_tracker(e)
rescue StandardError => e
@@ -179,5 +186,13 @@ module BulkImports
time_since_tracker_created > Pipeline::NDJSON_EXPORT_TIMEOUT
end
+
+ def enqueue_batches
+ 1.upto(export_status.batches_count) do |batch_number|
+ batch = pipeline_tracker.batches.find_or_create_by!(batch_number: batch_number) # rubocop:disable CodeReuse/ActiveRecord
+
+ ::BulkImports::PipelineBatchWorker.perform_async(batch.id)
+ end
+ end
end
end
diff --git a/app/workers/bulk_imports/relation_export_worker.rb b/app/workers/bulk_imports/relation_export_worker.rb
index b6693f0b07d..531edc6c7a7 100644
--- a/app/workers/bulk_imports/relation_export_worker.rb
+++ b/app/workers/bulk_imports/relation_export_worker.rb
@@ -18,9 +18,7 @@ module BulkImports
portable = portable(portable_id, portable_class)
config = BulkImports::FileTransfer.config_for(portable)
- if Feature.enabled?(:bulk_imports_batched_import_export) &&
- Gitlab::Utils.to_boolean(batched) &&
- config.batchable_relation?(relation)
+ if Gitlab::Utils.to_boolean(batched) && config.batchable_relation?(relation)
BatchedRelationExportService.new(user, portable, relation, jid).execute
else
RelationExportService.new(user, portable, relation, jid).execute
diff --git a/app/workers/ci/pipeline_cleanup_ref_worker.rb b/app/workers/ci/pipeline_cleanup_ref_worker.rb
new file mode 100644
index 00000000000..291e1090c18
--- /dev/null
+++ b/app/workers/ci/pipeline_cleanup_ref_worker.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Ci
+ class PipelineCleanupRefWorker
+ include ApplicationWorker
+ include Projects::RemoveRefs
+
+ sidekiq_options retry: 3
+ include PipelineQueue
+
+ idempotent!
+ deduplicate :until_executed, if_deduplicated: :reschedule_once, ttl: 1.minute
+ data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency
+
+ urgency :low
+
+ # Even though this worker is de-duplicated we need to acquire lock
+ # on a project to avoid running many concurrent refs removals
+ #
+ # TODO: Once underlying fix is done we can remove `in_lock`
+ #
+ # Related to:
+ # - https://gitlab.com/gitlab-org/gitaly/-/issues/5368
+ # - https://gitlab.com/gitlab-org/gitaly/-/issues/5369
+ def perform(pipeline_id)
+ pipeline = Ci::Pipeline.find_by_id(pipeline_id)
+ return unless pipeline
+ return unless pipeline.persistent_ref.should_delete?
+
+ serialized_remove_refs(pipeline.project_id) do
+ pipeline.reset.persistent_ref.delete
+ end
+ end
+ end
+end
diff --git a/app/workers/clusters/integrations/check_prometheus_health_worker.rb b/app/workers/clusters/integrations/check_prometheus_health_worker.rb
deleted file mode 100644
index b65b3424c3a..00000000000
--- a/app/workers/clusters/integrations/check_prometheus_health_worker.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Integrations
- class CheckPrometheusHealthWorker
- include ApplicationWorker
-
- data_consistency :always
-
- # rubocop:disable Scalability/CronWorkerContext
- # This worker does not perform work scoped to a context
- include CronjobQueue
- # rubocop:enable Scalability/CronWorkerContext
-
- feature_category :incident_management
- urgency :low
-
- idempotent!
- worker_has_external_dependencies!
-
- def perform; end
- end
- end
-end
diff --git a/app/workers/container_registry/cleanup_worker.rb b/app/workers/container_registry/cleanup_worker.rb
index 448a16ad309..9ec02dd613e 100644
--- a/app/workers/container_registry/cleanup_worker.rb
+++ b/app/workers/container_registry/cleanup_worker.rb
@@ -16,8 +16,6 @@ module ContainerRegistry
BATCH_SIZE = 200
def perform
- log_counts
-
reset_stale_deletes
delete_stale_ongoing_repair_details
@@ -54,26 +52,13 @@ module ContainerRegistry
end
def should_enqueue_record_detail_jobs?
- return false unless Gitlab.com?
+ return false unless Gitlab.com_except_jh?
return false unless Feature.enabled?(:registry_data_repair_worker)
return false unless ContainerRegistry::GitlabApiClient.supports_gitlab_api?
Project.pending_data_repair_analysis.exists?
end
- def log_counts
- ::Gitlab::Database::LoadBalancing::Session.current.use_replicas_for_read_queries do
- log_extra_metadata_on_done(
- :delete_scheduled_container_repositories_count,
- ContainerRepository.delete_scheduled.count
- )
- log_extra_metadata_on_done(
- :stale_delete_container_repositories_count,
- stale_delete_container_repositories.count
- )
- end
- end
-
def stale_delete_container_repositories
ContainerRepository.delete_ongoing.with_stale_delete_at(STALE_DELETE_THRESHOLD.ago)
end
diff --git a/app/workers/container_registry/record_data_repair_detail_worker.rb b/app/workers/container_registry/record_data_repair_detail_worker.rb
index 390481f8e01..3e40dbbb99a 100644
--- a/app/workers/container_registry/record_data_repair_detail_worker.rb
+++ b/app/workers/container_registry/record_data_repair_detail_worker.rb
@@ -17,7 +17,7 @@ module ContainerRegistry
LEASE_TIMEOUT = 1.hour.to_i
def perform_work
- return unless Gitlab.com?
+ return unless Gitlab.com_except_jh?
return unless next_project
return if next_project.container_registry_data_repair_detail
@@ -51,7 +51,7 @@ module ContainerRegistry
end
def remaining_work_count
- return 0 unless Gitlab.com?
+ return 0 unless Gitlab.com_except_jh?
return 0 unless Feature.enabled?(:registry_data_repair_worker)
return 0 unless ContainerRegistry::GitlabApiClient.supports_gitlab_api?
@@ -69,7 +69,7 @@ module ContainerRegistry
end
def next_project
- Project.pending_data_repair_analysis.first
+ Project.pending_data_repair_analysis.limit(max_running_jobs * 2).sample
end
strong_memoize_attr :next_project
diff --git a/app/workers/integrations/execute_worker.rb b/app/workers/integrations/execute_worker.rb
index 443f1d9fe8e..6fe1937a222 100644
--- a/app/workers/integrations/execute_worker.rb
+++ b/app/workers/integrations/execute_worker.rb
@@ -13,6 +13,8 @@ module Integrations
worker_has_external_dependencies!
def perform(hook_id, data)
+ return if ::Gitlab::SilentMode.enabled?
+
data = data.with_indifferent_access
integration = Integration.find_by_id(hook_id)
return unless integration
diff --git a/app/workers/integrations/group_mention_worker.rb b/app/workers/integrations/group_mention_worker.rb
new file mode 100644
index 00000000000..6cde1657ccd
--- /dev/null
+++ b/app/workers/integrations/group_mention_worker.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Integrations
+ class GroupMentionWorker
+ include ApplicationWorker
+
+ idempotent!
+ feature_category :integrations
+ deduplicate :until_executed
+ data_consistency :delayed
+ urgency :low
+
+ worker_has_external_dependencies!
+
+ def perform(args)
+ args = args.with_indifferent_access
+
+ mentionable_type = args[:mentionable_type]
+ mentionable_id = args[:mentionable_id]
+ hook_data = args[:hook_data]
+ is_confidential = args[:is_confidential]
+
+ mentionable = case mentionable_type
+ when 'Issue'
+ Issue.find(mentionable_id)
+ when 'MergeRequest'
+ MergeRequest.find(mentionable_id)
+ end
+
+ if mentionable.nil?
+ Sidekiq.logger.error(
+ message: 'Integrations::GroupMentionWorker: mentionable not supported',
+ mentionable_type: mentionable_type,
+ mentionable_id: mentionable_id
+ )
+ return
+ end
+
+ Integrations::GroupMentionService.new(mentionable, hook_data: hook_data, is_confidential: is_confidential).execute
+ end
+ end
+end
diff --git a/app/workers/merge_requests/cleanup_ref_worker.rb b/app/workers/merge_requests/cleanup_ref_worker.rb
new file mode 100644
index 00000000000..c714b976a2b
--- /dev/null
+++ b/app/workers/merge_requests/cleanup_ref_worker.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class CleanupRefWorker
+ include ApplicationWorker
+ include Projects::RemoveRefs
+
+ sidekiq_options retry: 3
+ loggable_arguments 2
+ feature_category :code_review_workflow
+
+ idempotent!
+ deduplicate :until_executed, if_deduplicated: :reschedule_once, ttl: 1.minute
+ data_consistency :delayed
+
+ urgency :low
+
+ # Even though this worker is de-duplicated we need to acquire lock
+ # on a project to avoid running many concurrent refs removals
+ #
+ # TODO: Once underlying fix is done we can remove `in_lock`
+ #
+ # Related to:
+ # - https://gitlab.com/gitlab-org/gitaly/-/issues/5368
+ # - https://gitlab.com/gitlab-org/gitaly/-/issues/5369
+ def perform(merge_request_id, only)
+ merge_request = MergeRequest.find_by_id(merge_request_id)
+ return unless merge_request
+
+ serialized_remove_refs(merge_request.target_project_id) do
+ merge_request.cleanup_refs(only: only.to_sym)
+ end
+ end
+ end
+end
diff --git a/app/workers/merge_requests/mergeability_check_batch_worker.rb b/app/workers/merge_requests/mergeability_check_batch_worker.rb
index cbe34ac3790..f48e9c234ab 100644
--- a/app/workers/merge_requests/mergeability_check_batch_worker.rb
+++ b/app/workers/merge_requests/mergeability_check_batch_worker.rb
@@ -15,10 +15,16 @@ module MergeRequests
@logger ||= Sidekiq.logger
end
- def perform(merge_request_ids)
+ def perform(merge_request_ids, user_id)
merge_requests = MergeRequest.id_in(merge_request_ids)
+ user = User.find_by_id(user_id)
merge_requests.each do |merge_request|
+ # Skip projects that user doesn't have update_merge_request access
+ next if merge_status_recheck_not_allowed?(merge_request, user)
+
+ merge_request.mark_as_checking
+
result = merge_request.check_mergeability
next unless result&.error?
@@ -30,5 +36,12 @@ module MergeRequests
)
end
end
+
+ private
+
+ def merge_status_recheck_not_allowed?(merge_request, user)
+ ::Feature.enabled?(:restrict_merge_status_recheck, merge_request.project) &&
+ !Ability.allowed?(user, :update_merge_request, merge_request.project)
+ end
end
end
diff --git a/app/workers/metrics/dashboard/prune_old_annotations_worker.rb b/app/workers/metrics/dashboard/prune_old_annotations_worker.rb
deleted file mode 100644
index 5b34f85606d..00000000000
--- a/app/workers/metrics/dashboard/prune_old_annotations_worker.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-module Metrics
- module Dashboard
- class PruneOldAnnotationsWorker
- include ApplicationWorker
-
- data_consistency :always
-
- sidekiq_options retry: 3
-
- DELETE_LIMIT = 10_000
- DEFAULT_CUT_OFF_PERIOD = 2.weeks
-
- feature_category :metrics
-
- idempotent! # in the scope of 24 hours
-
- def perform; end
- end
- end
-end
diff --git a/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb b/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb
deleted file mode 100644
index fe002ffa4a0..00000000000
--- a/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-module Metrics
- module Dashboard
- class ScheduleAnnotationsPruneWorker
- include ApplicationWorker
-
- data_consistency :always
-
- # rubocop:disable Scalability/CronWorkerContext
- # This worker does not perform work scoped to a context
- include CronjobQueue
- # rubocop:enable Scalability/CronWorkerContext
-
- feature_category :metrics
-
- idempotent! # PruneOldAnnotationsWorker worker is idempotent in the scope of 24 hours
-
- def perform; end
- end
- end
-end
diff --git a/app/workers/metrics/dashboard/sync_dashboards_worker.rb b/app/workers/metrics/dashboard/sync_dashboards_worker.rb
deleted file mode 100644
index 668542e51a5..00000000000
--- a/app/workers/metrics/dashboard/sync_dashboards_worker.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-module Metrics
- module Dashboard
- class SyncDashboardsWorker
- include ApplicationWorker
-
- data_consistency :always
-
- sidekiq_options retry: 3
-
- feature_category :metrics
-
- idempotent!
-
- def perform(project_id); end
- end
- end
-end
diff --git a/app/workers/packages/debian/process_changes_worker.rb b/app/workers/packages/debian/process_changes_worker.rb
deleted file mode 100644
index 0a716c61203..00000000000
--- a/app/workers/packages/debian/process_changes_worker.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-module Packages
- module Debian
- class ProcessChangesWorker
- include ApplicationWorker
-
- data_consistency :always
- include Gitlab::Utils::StrongMemoize
-
- deduplicate :until_executed
- idempotent!
-
- queue_namespace :package_repositories
- feature_category :package_registry
-
- def perform(package_file_id, user_id)
- @package_file_id = package_file_id
- @user_id = user_id
-
- return unless package_file && user
-
- ::Packages::Debian::ProcessChangesService.new(package_file, user).execute
- rescue StandardError => e
- Gitlab::ErrorTracking.log_exception(e, package_file_id: @package_file_id, user_id: @user_id)
- package_file.destroy!
- end
-
- private
-
- attr_reader :package_file_id, :user_id
-
- def package_file
- strong_memoize(:package_file) do
- ::Packages::PackageFile.find_by_id(package_file_id)
- end
- end
-
- def user
- strong_memoize(:user) do
- ::User.find_by_id(user_id)
- end
- end
- end
- end
-end
diff --git a/app/workers/redis_migration_worker.rb b/app/workers/redis_migration_worker.rb
new file mode 100644
index 00000000000..bad9baeac70
--- /dev/null
+++ b/app/workers/redis_migration_worker.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class RedisMigrationWorker
+ include ApplicationWorker
+
+ idempotent!
+ data_consistency :delayed
+ feature_category :redis
+ urgency :throttled
+ loggable_arguments 0
+
+ SCAN_START_STOP = '0'
+
+ def perform(job_class_name, cursor, options = {})
+ migrator = self.class.fetch_migrator!(job_class_name)
+
+ scan_size = options[:scan_size] || 1000
+ deadline = Time.now.utc + 3.minutes
+
+ while Time.now.utc < deadline
+ cursor, keys = migrator.redis.scan(cursor, match: migrator.scan_match_pattern, count: scan_size)
+
+ migrator.perform(keys) if keys.any?
+
+ sleep(0.01)
+ break if cursor == SCAN_START_STOP
+ end
+
+ self.class.perform_async(job_class_name, cursor, options) unless cursor == SCAN_START_STOP
+ end
+
+ class << self
+ def fetch_migrator!(job_class_name)
+ job_class = "Gitlab::BackgroundMigration::Redis::#{job_class_name}".safe_constantize
+ raise NotImplementedError, "#{job_class_name} does not exist" if job_class.nil?
+
+ job_class.new
+ end
+ end
+end
diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb
index 4ca366efcad..dab92e16ee3 100644
--- a/app/workers/run_pipeline_schedule_worker.rb
+++ b/app/workers/run_pipeline_schedule_worker.rb
@@ -33,10 +33,15 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker
def run_pipeline_schedule(schedule, user)
response = Ci::CreatePipelineService
.new(schedule.project, user, ref: schedule.ref)
- .execute(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule)
+ .execute(
+ :schedule,
+ save_on_errors: Feature.enabled?(:persist_failed_pipelines_from_schedules, schedule.project),
+ ignore_skip_ci: true, schedule: schedule
+ )
return response if response.payload.persisted?
+ # Remove with FF persist_failed_pipelines_from_schedules enabled, as corrupted yml is not longer logged
# This is a user operation error such as corrupted .gitlab-ci.yml. Log the error for debugging purpose.
log_extra_metadata_on_done(:pipeline_creation_error, response.message)
rescue StandardError => e