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
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts')
-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
641 files changed, 13317 insertions, 14341 deletions
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,
- },
- };
-};